This is an automated email from the ASF dual-hosted git repository.
Xuanwo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opendal.git
The following commit(s) were added to refs/heads/main by this push:
new ecd52fb87 fix(services/hf): avoid logging full HTML error pages (#7634)
ecd52fb87 is described below
commit ecd52fb87198e0c9f631284307498fedb4af6fd2
Author: Krisztián Szűcs <[email protected]>
AuthorDate: Fri May 29 09:12:58 2026 +0200
fix(services/hf): avoid logging full HTML error pages (#7634)
Use x-error-message response header instead of the response body for
error messages. The body is never read, preventing 52 KB HTML pages
from appearing in logs.
---
.github/actions/hf-temp-repo/common.js | 17 +++++++---
core/services/hf/src/core.rs | 32 +++++++++++-------
core/services/hf/src/error.rs | 60 ++++++++++------------------------
3 files changed, 49 insertions(+), 60 deletions(-)
diff --git a/.github/actions/hf-temp-repo/common.js
b/.github/actions/hf-temp-repo/common.js
index 7dfc33b36..c7196bd0d 100644
--- a/.github/actions/hf-temp-repo/common.js
+++ b/.github/actions/hf-temp-repo/common.js
@@ -39,11 +39,18 @@ function hfRequest(method, path, token, body) {
(res) => {
let result = "";
res.on("data", (chunk) => (result += chunk));
- res.on("end", () =>
- res.statusCode >= 200 && res.statusCode < 300
- ? resolve(result)
- : reject(new Error(`HTTP ${res.statusCode}: ${result}`))
- );
+ res.on("end", () => {
+ if (res.statusCode >= 200 && res.statusCode < 300) {
+ resolve(result);
+ } else {
+ // Prefer the structured error header to avoid logging large HTML
pages.
+ const msg =
+ res.headers["x-error-message"] ||
+ result.slice(0, 200) ||
+ "unknown error";
+ reject(new Error(`HTTP ${res.statusCode}: ${msg}`));
+ }
+ });
}
);
req.on("error", reject);
diff --git a/core/services/hf/src/core.rs b/core/services/hf/src/core.rs
index bc604f241..7a353a78b 100644
--- a/core/services/hf/src/core.rs
+++ b/core/services/hf/src/core.rs
@@ -402,14 +402,18 @@ impl HfCore {
.to_string()
}
- /// Send a request and return the successful response or a parsed error.
- async fn send(&self, req: Request<Buffer>) -> Result<Response<Buffer>> {
- let resp = self.info.http_client().send(req).await?;
- if resp.status().is_success() {
- Ok(resp)
+ /// Send a request and return the successful streaming response or a
parsed error.
+ ///
+ /// Uses `fetch` so error response bodies are never read into memory —
+ /// `parse_error` reads only response headers.
+ async fn send(&self, req: Request<Buffer>) -> Result<Response<HttpBody>> {
+ let resp = self.info.http_client().fetch(req).await?;
+ let (parts, body) = resp.into_parts();
+ if parts.status.is_success() {
+ Ok(Response::from_parts(parts, body))
} else {
- let (parts, body) = resp.into_parts();
- Err(parse_error(parts, body))
+ // Drop the streaming body without reading it.
+ Err(parse_error(parts))
}
}
@@ -421,8 +425,10 @@ impl HfCore {
&self,
req: Request<Buffer>,
) -> Result<(http::response::Parts, T)> {
- let (parts, body) = self.send(req).await?.into_parts();
- let parsed =
serde_json::from_reader(body.reader()).map_err(new_json_deserialize_error)?;
+ let (parts, mut body) = self.send(req).await?.into_parts();
+ let buffer = body.to_buffer().await?;
+ let parsed =
+
serde_json::from_reader(buffer.reader()).map_err(new_json_deserialize_error)?;
Ok((parts, parsed))
}
@@ -474,9 +480,11 @@ impl HfCore {
let resp = self.info.http_client().fetch(req).await?;
if !resp.status().is_success() {
- let (parts, mut body) = resp.into_parts();
- let buf = body.to_buffer().await?;
- return Err(parse_error(parts, buf));
+ // Drop the streaming body without reading it — parse_error reads
+ // only response headers, so there is no need to buffer the body
+ // (which may be a large HTML error page).
+ let (parts, _) = resp.into_parts();
+ return Err(parse_error(parts));
}
Ok(resp)
diff --git a/core/services/hf/src/error.rs b/core/services/hf/src/error.rs
index 6fcc6ccd0..b6ac8c885 100644
--- a/core/services/hf/src/error.rs
+++ b/core/services/hf/src/error.rs
@@ -15,33 +15,21 @@
// specific language governing permissions and limitations
// under the License.
-use std::fmt::Debug;
-
use http::StatusCode;
-use serde::Deserialize;
use opendal_core::raw::*;
use opendal_core::*;
-#[derive(Default, Deserialize)]
-struct HfError {
- error: String,
-}
-
-impl Debug for HfError {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- f.debug_struct("HfError")
- .field("message", &self.error.replace('\n', " "))
- .finish()
- }
-}
-
-pub(super) fn parse_error(parts: http::response::Parts, body: Buffer) -> Error
{
- let bs = body.to_bytes();
- let message = match serde_json::from_slice::<HfError>(&bs) {
- Ok(hf_error) => hf_error.error,
- Err(_) => String::from_utf8_lossy(&bs).into_owned(),
- };
+pub(super) fn parse_error(parts: http::response::Parts) -> Error {
+ // HF sets x-error-message on every error response with a short
human-readable
+ // description. Using the header avoids reading the response body, which
can be
+ // a large HTML error page (e.g. 52 KB on 404s from the /resolve/
endpoint).
+ let message = parts
+ .headers
+ .get("x-error-message")
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or("unknown error")
+ .to_string();
// HF git-style commit APIs reject stale branch snapshots with 412.
// Treat this specific conflict as temporary so RetryLayer can replay
@@ -80,33 +68,19 @@ mod test {
use super::*;
- #[test]
- fn test_parse_error() -> Result<()> {
- let resp = r#"
- {
- "error": "Invalid username or password."
- }
- "#;
- let decoded_response =
serde_json::from_slice::<HfError>(resp.as_bytes())
- .map_err(new_json_deserialize_error)?;
-
- assert_eq!(decoded_response.error, "Invalid username or password.");
-
- Ok(())
- }
-
#[test]
fn test_parse_error_branch_update_conflict_is_temporary() {
- let body = Buffer::from(bytes::Bytes::from(
- r#"{"error":"The branch was updated since you opened this page.
Please refresh and try again."}"#,
- ));
let (parts, _) = Response::builder()
.status(StatusCode::PRECONDITION_FAILED)
+ .header(
+ "x-error-message",
+ "The branch was updated since you opened this page. Please
refresh and try again.",
+ )
.body(())
.unwrap()
.into_parts();
- let err = parse_error(parts, body);
+ let err = parse_error(parts);
assert_eq!(err.kind(), ErrorKind::ConditionNotMatch);
assert!(err.is_temporary());
@@ -114,14 +88,14 @@ mod test {
#[test]
fn test_parse_error_other_precondition_failed_is_not_temporary() {
- let body = Buffer::from(bytes::Bytes::from(r#"{"error":"etag
mismatch"}"#));
let (parts, _) = Response::builder()
.status(StatusCode::PRECONDITION_FAILED)
+ .header("x-error-message", "etag mismatch")
.body(())
.unwrap()
.into_parts();
- let err = parse_error(parts, body);
+ let err = parse_error(parts);
assert_eq!(err.kind(), ErrorKind::ConditionNotMatch);
assert!(!err.is_temporary());