Skip to main content

hf_hub/
error.rs

1use std::time::Duration;
2
3use reqwest::header::HeaderMap;
4use thiserror::Error;
5
6/// Context captured from a failed HTTP response, carried by every HTTP-derived
7/// variant of [`HFError`]. `request_id`, `error_code`, and `server_message` come
8/// from the Hub's `X-Request-Id`, `X-Error-Code`, and `X-Error-Message` headers;
9/// `server_message` falls back to the JSON body's `"error"` field.
10///
11/// `#[non_exhaustive]`: destructure with `..` and use the public constructors —
12/// new fields may be added without a SemVer break.
13#[derive(Debug, Clone)]
14#[non_exhaustive]
15pub struct HttpErrorContext {
16    pub status: reqwest::StatusCode,
17    pub url: String,
18    pub request_id: Option<String>,
19    pub error_code: Option<String>,
20    /// Best-effort human-readable message from headers or JSON body.
21    pub server_message: Option<String>,
22    pub body: String,
23}
24
25impl HttpErrorContext {
26    /// Build context from a non-success response. Drains the response body.
27    pub(crate) async fn from_response(response: reqwest::Response) -> Self {
28        let status = response.status();
29        let url = response.url().to_string();
30        let request_id = header_string(response.headers(), "x-request-id");
31        let error_code = header_string(response.headers(), "x-error-code");
32        let header_message = header_string(response.headers(), "x-error-message");
33        let body = response.text().await.unwrap_or_default();
34        let server_message = header_message.or_else(|| extract_json_error(&body));
35        Self {
36            status,
37            url,
38            request_id,
39            error_code,
40            server_message,
41            body,
42        }
43    }
44}
45
46fn header_string(headers: &HeaderMap, name: &str) -> Option<String> {
47    headers.get(name)?.to_str().ok().map(|s| s.to_string())
48}
49
50fn extract_json_error(body: &str) -> Option<String> {
51    let value: serde_json::Value = serde_json::from_str(body).ok()?;
52    value.get("error")?.as_str().map(|s| s.to_string())
53}
54
55/// Parse an integer-seconds `Retry-After` header. HTTP-date form is not supported.
56pub(crate) fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
57    let raw = headers.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?;
58    raw.trim().parse::<u64>().ok().map(Duration::from_secs)
59}
60
61fn format_http_detail(ctx: &HttpErrorContext) -> String {
62    let mut detail = String::new();
63
64    if let Some(message) = ctx.server_message.as_deref().filter(|m| !m.is_empty()) {
65        detail.push_str(": ");
66        detail.push_str(message);
67    }
68
69    let mut fields = vec![format!("url={}", ctx.url)];
70    if let Some(rid) = &ctx.request_id {
71        fields.push(format!("request_id={rid}"));
72    }
73    if let Some(code) = &ctx.error_code {
74        fields.push(format!("error_code={code}"));
75    }
76    detail.push_str(&format!(" ({})", fields.join(", ")));
77
78    detail
79}
80
81/// Error type returned by public `hf-hub` APIs.
82///
83/// Match on the specific variants first for common Hub cases such as
84/// authentication, missing repos/files/revisions, forbidden access, and rate
85/// limiting. Lower-level transport failures use [`HFError::Request`], while
86/// unmapped HTTP responses use [`HFError::Http`].
87///
88/// This enum is `#[non_exhaustive]`: external matches must include a wildcard
89/// arm so new variants can be added without a SemVer break.
90#[derive(Error, Debug)]
91#[non_exhaustive]
92pub enum HFError {
93    /// Non-success HTTP response that was not mapped to a more specific
94    /// variant.
95    #[error("HTTP error: {}{}", .context.status, format_http_detail(.context))]
96    Http {
97        /// Response metadata captured from the server.
98        context: Box<HttpErrorContext>,
99    },
100
101    /// Authentication is required or the configured token was rejected.
102    #[error("Authentication required{}", format_http_detail(.context))]
103    AuthRequired {
104        /// Response metadata captured from the server.
105        context: Box<HttpErrorContext>,
106    },
107
108    /// Repository was not found.
109    #[error("Repository not found: {repo_id}")]
110    RepoNotFound {
111        /// Repository id, usually in `owner/name` form.
112        repo_id: String,
113        /// Original HTTP response context, when available.
114        context: Option<Box<HttpErrorContext>>,
115    },
116
117    /// Revision was not found within a repository.
118    #[error("Revision not found: {revision} in {repo_id}")]
119    RevisionNotFound {
120        /// Repository id, usually in `owner/name` form.
121        repo_id: String,
122        /// Missing revision name or commit SHA.
123        revision: String,
124        /// Original HTTP response context, when available.
125        context: Option<Box<HttpErrorContext>>,
126    },
127
128    /// File or path was not found within a repository or bucket.
129    #[error("Entry not found: {path} in {repo_id}")]
130    EntryNotFound {
131        /// Missing repository-relative or bucket-relative path.
132        path: String,
133        /// Repository or bucket id associated with the lookup.
134        repo_id: String,
135        /// Original HTTP response context, when available.
136        context: Option<Box<HttpErrorContext>>,
137    },
138
139    /// Bucket was not found.
140    #[error("Bucket not found: {bucket_id}")]
141    BucketNotFound {
142        /// Bucket id in `owner/name` form.
143        bucket_id: String,
144        /// Original HTTP response context, when available.
145        context: Option<Box<HttpErrorContext>>,
146    },
147
148    /// Credentials are valid, but the caller is not allowed to perform the
149    /// operation.
150    #[error("Forbidden{}", format_http_detail(.context))]
151    Forbidden {
152        /// Response metadata captured from the server.
153        context: Box<HttpErrorContext>,
154    },
155
156    /// Server reported a conflict, such as an already-existing resource or a
157    /// branch head mismatch.
158    #[error("Conflict{}", format_http_detail(.context))]
159    Conflict {
160        /// Response metadata captured from the server.
161        context: Box<HttpErrorContext>,
162    },
163
164    /// Request was rate limited by the Hub.
165    #[error("Rate limited{}", format_http_detail(.context))]
166    RateLimited {
167        /// Parsed `Retry-After` delay, when the server returned one.
168        retry_after: Option<Duration>,
169        /// Response metadata captured from the server.
170        context: Box<HttpErrorContext>,
171    },
172
173    /// File was not found in the local cache during a cache-only lookup.
174    #[error("File not found in local cache: {path}")]
175    LocalEntryNotFound {
176        /// Missing cached path.
177        path: String,
178    },
179
180    /// The operation requires the local cache, but caching is disabled.
181    #[error(
182        "Cache is not enabled — set cache_enabled(true) on HFClientBuilder, or set .local_dir(...) on the download builder"
183    )]
184    CacheNotEnabled,
185
186    /// Timed out waiting for an on-disk cache lock.
187    #[error("Cache lock timed out: {}", path.display())]
188    CacheLockTimeout {
189        /// Path of the lock file or locked entry.
190        path: std::path::PathBuf,
191    },
192
193    /// Transport-level HTTP client error before a usable Hub response was
194    /// produced.
195    #[error("HTTP request error: {source}{}", .url.as_deref().map(|u| format!(" ({u})")).unwrap_or_default())]
196    Request {
197        /// Underlying `reqwest` transport error.
198        #[source]
199        source: reqwest::Error,
200        /// Request URL, when known.
201        url: Option<String>,
202    },
203
204    /// Filesystem I/O error.
205    #[error(transparent)]
206    Io(#[from] std::io::Error),
207
208    /// JSON serialization or deserialization error.
209    #[error(transparent)]
210    Json(#[from] serde_json::Error),
211
212    /// URL parsing error.
213    #[error(transparent)]
214    Url(#[from] url::ParseError),
215
216    /// Caller provided an invalid parameter value.
217    #[error("Invalid parameter: {0}")]
218    InvalidParameter(String),
219
220    /// Raw diff parsing error.
221    #[error(transparent)]
222    DiffParse(#[from] crate::repository::HFDiffParseError),
223
224    /// A xet (high-performance content-addressable) operation failed.
225    ///
226    /// The [`operation`](Self::Xet::operation) field identifies which step
227    /// failed; [`source`](Self::Xet::source) carries the underlying error
228    /// (typically a `xet_core` error or a `tokio` join failure).
229    #[error("Xet {operation} failed: {source}")]
230    Xet {
231        /// Which xet operation produced the error.
232        operation: XetOperation,
233        /// Underlying error, type-erased so the `xet_core` types stay out of the public API.
234        #[source]
235        source: Box<dyn std::error::Error + Send + Sync>,
236    },
237
238    /// Hub responded with success but the response is missing data the client
239    /// needs to proceed (e.g., an `ETag` or `X-Repo-Commit` header that should
240    /// always be present, or a `304 Not Modified` without a corresponding
241    /// cached state).
242    ///
243    /// Distinct from [`Http`](Self::Http): the status code was a success, the
244    /// shape of the response was wrong.
245    #[error(
246        "Hub response missing required data: {what}{}",
247        url.as_deref().map(|u| format!(" ({u})")).unwrap_or_default()
248    )]
249    MalformedResponse {
250        /// Short description of what was missing or unexpected.
251        what: String,
252        /// URL of the request that produced the bad response, when known.
253        url: Option<String>,
254    },
255
256    /// Catch-all error for cases that do not fit another variant.
257    #[error("{0}")]
258    Other(String),
259}
260
261/// Identifies the xet operation associated with an [`HFError::Xet`] error.
262///
263/// New variants may be added in the future; matches must include a wildcard arm.
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265#[non_exhaustive]
266pub enum XetOperation {
267    /// Building or refreshing the cached `XetSession` used for transfers.
268    Session,
269    /// Single-file or multi-file upload via xet.
270    Upload,
271    /// Single-file download via xet (writing to the cache or a local dir).
272    Download,
273    /// Multi-file batch download via xet.
274    BatchDownload,
275    /// Streaming download via xet (returns bytes to the caller as they arrive).
276    StreamDownload,
277    /// Bucket-level batch download via xet.
278    BucketBatchDownload,
279}
280
281impl std::fmt::Display for XetOperation {
282    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283        let s = match self {
284            XetOperation::Session => "session",
285            XetOperation::Upload => "upload",
286            XetOperation::Download => "download",
287            XetOperation::BatchDownload => "batch download",
288            XetOperation::StreamDownload => "stream download",
289            XetOperation::BucketBatchDownload => "bucket batch download",
290        };
291        f.write_str(s)
292    }
293}
294
295impl From<reqwest::Error> for HFError {
296    fn from(source: reqwest::Error) -> Self {
297        let url = source.url().map(|u| u.to_string());
298        HFError::Request { source, url }
299    }
300}
301
302impl HFError {
303    /// Construct an [`HFError::Xet`] from a typed source error.
304    ///
305    /// The source is type-erased into `Box<dyn Error + Send + Sync>` so that
306    /// upstream `xet_core` types stay out of the public API; downstream
307    /// matchers can use `source.downcast_ref::<T>()` if they need the concrete
308    /// type.
309    pub fn xet<E>(operation: XetOperation, source: E) -> Self
310    where
311        E: std::error::Error + Send + Sync + 'static,
312    {
313        HFError::Xet {
314            operation,
315            source: Box::new(source),
316        }
317    }
318
319    /// Construct a [`HFError::MalformedResponse`] without a known URL.
320    pub fn malformed_response(what: impl Into<String>) -> Self {
321        HFError::MalformedResponse {
322            what: what.into(),
323            url: None,
324        }
325    }
326
327    /// Construct a [`HFError::MalformedResponse`] with the originating URL attached.
328    pub fn malformed_response_at(what: impl Into<String>, url: impl Into<String>) -> Self {
329        HFError::MalformedResponse {
330            what: what.into(),
331            url: Some(url.into()),
332        }
333    }
334
335    /// Returns true for errors that indicate transient network/server issues
336    /// where falling back to a cached version is appropriate.
337    pub(crate) fn is_transient(&self) -> bool {
338        match self {
339            // `reqwest::Error::is_connect` is not available on the wasm32 backend.
340            HFError::Request { source, .. } => {
341                let timeout = source.is_timeout();
342                let connect = {
343                    #[cfg(not(target_family = "wasm"))]
344                    {
345                        source.is_connect()
346                    }
347                    #[cfg(target_family = "wasm")]
348                    {
349                        false
350                    }
351                };
352                timeout || connect
353            },
354            HFError::Http { context } => {
355                matches!(context.status.as_u16(), 500 | 502 | 503 | 504)
356            },
357            _ => false,
358        }
359    }
360}
361
362/// Convenience alias used by public `hf-hub` APIs.
363///
364/// Equivalent to `Result<T, HFError>`.
365pub type HFResult<T> = Result<T, HFError>;
366
367/// Context for mapping HTTP 404 errors to specific HFError variants.
368pub(crate) enum NotFoundContext {
369    /// 404 means the repository does not exist
370    Repo,
371    /// 404 means the bucket does not exist
372    Bucket,
373    /// 404 means a file/path does not exist within the repo
374    Entry { path: String },
375    /// 404 means the revision does not exist
376    Revision { revision: String },
377    /// No special mapping — use generic Http error
378    Generic,
379}
380
381#[cfg(test)]
382mod tests {
383    use reqwest::StatusCode;
384    use reqwest::header::{HeaderMap, HeaderValue};
385
386    use super::*;
387
388    #[test]
389    fn retry_after_parses_integer_seconds() {
390        let mut h = HeaderMap::new();
391        h.insert(reqwest::header::RETRY_AFTER, HeaderValue::from_static("42"));
392        assert_eq!(parse_retry_after(&h), Some(Duration::from_secs(42)));
393    }
394
395    #[test]
396    fn retry_after_rejects_http_date() {
397        let mut h = HeaderMap::new();
398        h.insert(reqwest::header::RETRY_AFTER, HeaderValue::from_static("Wed, 21 Oct 2015 07:28:00 GMT"));
399        assert_eq!(parse_retry_after(&h), None);
400    }
401
402    #[test]
403    fn retry_after_absent() {
404        let h = HeaderMap::new();
405        assert_eq!(parse_retry_after(&h), None);
406    }
407
408    #[test]
409    fn header_string_case_insensitive() {
410        let mut h = HeaderMap::new();
411        h.insert("X-Request-Id", HeaderValue::from_static("abc123"));
412        assert_eq!(header_string(&h, "x-request-id"), Some("abc123".to_string()));
413    }
414
415    #[test]
416    fn json_error_extraction() {
417        let body = r#"{"error": "gated repository"}"#;
418        assert_eq!(extract_json_error(body), Some("gated repository".to_string()));
419
420        assert_eq!(extract_json_error("not json"), None);
421        assert_eq!(extract_json_error(r#"{"other": "x"}"#), None);
422    }
423
424    fn ctx(status: u16) -> HttpErrorContext {
425        HttpErrorContext {
426            status: StatusCode::from_u16(status).unwrap(),
427            url: "https://example".to_string(),
428            request_id: None,
429            error_code: None,
430            server_message: None,
431            body: String::new(),
432        }
433    }
434
435    #[test]
436    fn is_transient_classifies_http_statuses() {
437        for s in [500u16, 502, 503, 504] {
438            assert!(
439                HFError::Http {
440                    context: Box::new(ctx(s))
441                }
442                .is_transient(),
443                "{s} should be transient"
444            );
445        }
446        for s in [400u16, 401, 403, 404, 409, 429] {
447            assert!(
448                !HFError::Http {
449                    context: Box::new(ctx(s))
450                }
451                .is_transient(),
452                "{s} should not be transient"
453            );
454        }
455    }
456
457    #[test]
458    fn display_includes_request_id_when_present() {
459        let mut c = ctx(500);
460        c.request_id = Some("req-xyz".to_string());
461        let msg = HFError::Http { context: Box::new(c) }.to_string();
462        assert!(msg.contains("req-xyz"), "display missing request_id: {msg}");
463    }
464
465    #[test]
466    fn display_includes_server_message_when_present() {
467        let mut c = ctx(400);
468        c.server_message = Some("You can't create a commit with more than 1000 files".to_string());
469        c.request_id = Some("req-xyz".to_string());
470        let msg = HFError::Http { context: Box::new(c) }.to_string();
471        assert!(
472            msg.contains("You can't create a commit with more than 1000 files"),
473            "display missing server_message: {msg}"
474        );
475        assert!(msg.contains("req-xyz"), "display missing request_id: {msg}");
476    }
477
478    #[test]
479    fn display_surfaces_server_message_across_variants() {
480        let make = |status| {
481            let mut c = ctx(status);
482            c.server_message = Some("server explanation".to_string());
483            Box::new(c)
484        };
485        for err in [
486            HFError::Http { context: make(400) },
487            HFError::AuthRequired { context: make(401) },
488            HFError::Forbidden { context: make(403) },
489            HFError::Conflict { context: make(409) },
490            HFError::RateLimited {
491                retry_after: None,
492                context: make(429),
493            },
494        ] {
495            let msg = err.to_string();
496            assert!(msg.contains("server explanation"), "variant dropped server_message: {msg}");
497        }
498    }
499
500    #[test]
501    fn display_omits_message_section_when_absent() {
502        // server_message is None on the bare ctx(); the rendered string should carry the URL in
503        // the parenthetical without a dangling ": " where the message would go.
504        let msg = HFError::Http {
505            context: Box::new(ctx(500)),
506        }
507        .to_string();
508        assert_eq!(msg, "HTTP error: 500 Internal Server Error (url=https://example)");
509    }
510
511    #[tokio::test]
512    async fn from_response_extracts_headers_and_body() {
513        // Spin up a tiny server that returns 500 with custom headers + body.
514        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
515        let addr = listener.local_addr().unwrap();
516
517        tokio::spawn(async move {
518            let (mut sock, _) = listener.accept().await.unwrap();
519            let mut buf = vec![0u8; 1024];
520            let _ = tokio::io::AsyncReadExt::read(&mut sock, &mut buf).await;
521            let body = r#"{"error":"server said no"}"#;
522            let resp = format!(
523                "HTTP/1.1 500 Internal Server Error\r\n\
524                 Content-Type: application/json\r\n\
525                 Content-Length: {}\r\n\
526                 X-Request-Id: req-123\r\n\
527                 X-Error-Code: GatedRepo\r\n\
528                 X-Error-Message: acceptance required\r\n\
529                 \r\n{}",
530                body.len(),
531                body
532            );
533            tokio::io::AsyncWriteExt::write_all(&mut sock, resp.as_bytes()).await.unwrap();
534        });
535
536        let response = reqwest::get(format!("http://{addr}/test")).await.unwrap();
537        let ctx = HttpErrorContext::from_response(response).await;
538
539        assert_eq!(ctx.status, StatusCode::INTERNAL_SERVER_ERROR);
540        assert!(ctx.url.ends_with("/test"));
541        assert_eq!(ctx.request_id.as_deref(), Some("req-123"));
542        assert_eq!(ctx.error_code.as_deref(), Some("GatedRepo"));
543        assert_eq!(ctx.server_message.as_deref(), Some("acceptance required"));
544        assert!(ctx.body.contains("server said no"));
545    }
546
547    #[tokio::test]
548    async fn from_response_falls_back_to_json_error() {
549        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
550        let addr = listener.local_addr().unwrap();
551
552        tokio::spawn(async move {
553            let (mut sock, _) = listener.accept().await.unwrap();
554            let mut buf = vec![0u8; 1024];
555            let _ = tokio::io::AsyncReadExt::read(&mut sock, &mut buf).await;
556            let body = r#"{"error":"repo is gated"}"#;
557            let resp = format!(
558                "HTTP/1.1 403 Forbidden\r\n\
559                 Content-Type: application/json\r\n\
560                 Content-Length: {}\r\n\
561                 \r\n{}",
562                body.len(),
563                body
564            );
565            tokio::io::AsyncWriteExt::write_all(&mut sock, resp.as_bytes()).await.unwrap();
566        });
567
568        let response = reqwest::get(format!("http://{addr}/")).await.unwrap();
569        let ctx = HttpErrorContext::from_response(response).await;
570
571        assert_eq!(ctx.request_id, None);
572        assert_eq!(ctx.server_message.as_deref(), Some("repo is gated"));
573    }
574}