1use std::time::Duration;
2
3use reqwest::header::HeaderMap;
4use thiserror::Error;
5
6#[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 pub server_message: Option<String>,
22 pub body: String,
23}
24
25impl HttpErrorContext {
26 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
55pub(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#[derive(Error, Debug)]
91#[non_exhaustive]
92pub enum HFError {
93 #[error("HTTP error: {}{}", .context.status, format_http_detail(.context))]
96 Http {
97 context: Box<HttpErrorContext>,
99 },
100
101 #[error("Authentication required{}", format_http_detail(.context))]
103 AuthRequired {
104 context: Box<HttpErrorContext>,
106 },
107
108 #[error("Repository not found: {repo_id}")]
110 RepoNotFound {
111 repo_id: String,
113 context: Option<Box<HttpErrorContext>>,
115 },
116
117 #[error("Revision not found: {revision} in {repo_id}")]
119 RevisionNotFound {
120 repo_id: String,
122 revision: String,
124 context: Option<Box<HttpErrorContext>>,
126 },
127
128 #[error("Entry not found: {path} in {repo_id}")]
130 EntryNotFound {
131 path: String,
133 repo_id: String,
135 context: Option<Box<HttpErrorContext>>,
137 },
138
139 #[error("Bucket not found: {bucket_id}")]
141 BucketNotFound {
142 bucket_id: String,
144 context: Option<Box<HttpErrorContext>>,
146 },
147
148 #[error("Forbidden{}", format_http_detail(.context))]
151 Forbidden {
152 context: Box<HttpErrorContext>,
154 },
155
156 #[error("Conflict{}", format_http_detail(.context))]
159 Conflict {
160 context: Box<HttpErrorContext>,
162 },
163
164 #[error("Rate limited{}", format_http_detail(.context))]
166 RateLimited {
167 retry_after: Option<Duration>,
169 context: Box<HttpErrorContext>,
171 },
172
173 #[error("File not found in local cache: {path}")]
175 LocalEntryNotFound {
176 path: String,
178 },
179
180 #[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 #[error("Cache lock timed out: {}", path.display())]
188 CacheLockTimeout {
189 path: std::path::PathBuf,
191 },
192
193 #[error("HTTP request error: {source}{}", .url.as_deref().map(|u| format!(" ({u})")).unwrap_or_default())]
196 Request {
197 #[source]
199 source: reqwest::Error,
200 url: Option<String>,
202 },
203
204 #[error(transparent)]
206 Io(#[from] std::io::Error),
207
208 #[error(transparent)]
210 Json(#[from] serde_json::Error),
211
212 #[error(transparent)]
214 Url(#[from] url::ParseError),
215
216 #[error("Invalid parameter: {0}")]
218 InvalidParameter(String),
219
220 #[error(transparent)]
222 DiffParse(#[from] crate::repository::HFDiffParseError),
223
224 #[error("Xet {operation} failed: {source}")]
230 Xet {
231 operation: XetOperation,
233 #[source]
235 source: Box<dyn std::error::Error + Send + Sync>,
236 },
237
238 #[error(
246 "Hub response missing required data: {what}{}",
247 url.as_deref().map(|u| format!(" ({u})")).unwrap_or_default()
248 )]
249 MalformedResponse {
250 what: String,
252 url: Option<String>,
254 },
255
256 #[error("{0}")]
258 Other(String),
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265#[non_exhaustive]
266pub enum XetOperation {
267 Session,
269 Upload,
271 Download,
273 BatchDownload,
275 StreamDownload,
277 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 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 pub fn malformed_response(what: impl Into<String>) -> Self {
321 HFError::MalformedResponse {
322 what: what.into(),
323 url: None,
324 }
325 }
326
327 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 pub(crate) fn is_transient(&self) -> bool {
338 match self {
339 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
362pub type HFResult<T> = Result<T, HFError>;
366
367pub(crate) enum NotFoundContext {
369 Repo,
371 Bucket,
373 Entry { path: String },
375 Revision { revision: String },
377 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 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 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}