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());

Reply via email to