hf_fetch_model/error.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Error types for hf-fetch-model.
4//!
5//! All fallible operations in this crate return [`FetchError`].
6//! [`FileFailure`] provides structured per-file error reporting.
7
8use std::path::PathBuf;
9
10/// Errors that can occur during model fetching.
11#[derive(Debug, thiserror::Error)]
12#[non_exhaustive]
13pub enum FetchError {
14 /// The `hf-hub` API returned an error.
15 ///
16 /// Wraps `hf_hub::HFError` (was `hf_hub::api::tokio::ApiError` before the
17 /// `hf-hub` 1.0 rewrite, which collapsed the per-backend error types into
18 /// one).
19 #[error("hf-hub API error: {0}")]
20 Api(#[from] hf_hub::HFError),
21
22 /// An I/O error occurred while accessing the local filesystem.
23 #[error("I/O error at {path}: {source}")]
24 Io {
25 /// The path that caused the error.
26 path: PathBuf,
27 /// The underlying I/O error.
28 source: std::io::Error,
29 },
30
31 /// The repository was not found or is inaccessible.
32 #[error("repository not found: {repo_id}")]
33 RepoNotFound {
34 /// The repository identifier that was not found.
35 repo_id: String,
36 },
37
38 /// Authentication failed: a gated repository was requested without a
39 /// token, or the supplied token was rejected (HTTP 401/403).
40 ///
41 /// Returned by the gated-model pre-flight in `download` /
42 /// `download_with_config` before any transfer starts; non-retryable. The
43 /// library `inspect` functions instead surface the raw HTTP status as
44 /// [`FetchError::Http`] (the `hf-fm` CLI upgrades those into this same
45 /// diagnosis). See the crate-level *Authentication* section.
46 #[error("authentication failed: {reason}")]
47 Auth {
48 /// Description of the authentication failure.
49 reason: String,
50 },
51
52 /// An invalid glob pattern was provided for filtering.
53 #[error("invalid glob pattern: {pattern}: {reason}")]
54 InvalidPattern {
55 /// The glob pattern that failed to parse.
56 pattern: String,
57 /// Description of the parse error.
58 reason: String,
59 },
60
61 /// SHA256 checksum mismatch after download.
62 #[error("checksum mismatch for {filename}: expected {expected}, got {actual}")]
63 Checksum {
64 /// The filename that failed verification.
65 filename: String,
66 /// The expected SHA256 hex digest.
67 expected: String,
68 /// The actual SHA256 hex digest computed from the file.
69 actual: String,
70 },
71
72 /// A download operation timed out.
73 #[error("timeout downloading {filename} after {seconds}s")]
74 Timeout {
75 /// The filename that timed out.
76 filename: String,
77 /// The timeout duration in seconds.
78 seconds: u64,
79 },
80
81 /// One or more files failed to download.
82 ///
83 /// Contains the successful path and a list of per-file failures.
84 #[error("{} file(s) failed to download:{}", failures.len(), format_failures(failures))]
85 PartialDownload {
86 /// The snapshot directory (if any files succeeded).
87 path: Option<PathBuf>,
88 /// Per-file failure details.
89 failures: Vec<FileFailure>,
90 },
91
92 /// A chunked (multi-connection) download failed.
93 #[error("chunked download failed for {filename}: {reason}")]
94 ChunkedDownload {
95 /// The filename that failed.
96 filename: String,
97 /// Description of the failure.
98 reason: String,
99 },
100
101 /// An HTTP request to the `HuggingFace` API failed.
102 #[error("HTTP error: {0}")]
103 Http(String),
104
105 /// An invalid argument was provided.
106 #[error("{0}")]
107 InvalidArgument(String),
108
109 /// The repository exists but no files matched after filtering,
110 /// or the repository contains no files at all.
111 #[error("no files matched in repository {repo_id}")]
112 NoFilesMatched {
113 /// The repository identifier.
114 repo_id: String,
115 },
116
117 /// A `.safetensors` header is malformed or cannot be parsed.
118 #[error("safetensors header error for {filename}: {reason}")]
119 SafetensorsHeader {
120 /// The filename whose header failed to parse.
121 filename: String,
122 /// Description of the parse failure.
123 reason: String,
124 },
125
126 /// `inspect` was asked to read a file whose extension is not supported.
127 ///
128 /// Emitted before any parse attempt so users see a clear format mismatch
129 /// rather than a misleading header-parse error.
130 #[error(
131 "hf-fm inspect supports .safetensors, .gguf, .npz, or .pth (got .{extension} for {filename})"
132 )]
133 UnsupportedInspectFormat {
134 /// The filename whose extension is unsupported.
135 filename: String,
136 /// The actual extension without the leading dot, or `unknown` if none.
137 extension: String,
138 },
139}
140
141/// A per-file download failure with structured context.
142#[derive(Debug, Clone)]
143pub struct FileFailure {
144 /// The filename that failed.
145 pub filename: String,
146 /// Human-readable description of the failure.
147 pub reason: String,
148 /// Whether this failure is likely to succeed on retry.
149 pub retryable: bool,
150}
151
152impl std::fmt::Display for FileFailure {
153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 write!(
155 f,
156 "{}: {} (retryable: {})",
157 self.filename, self.reason, self.retryable
158 )
159 }
160}
161
162/// Formats a list of file failures for inclusion in the `PartialDownload` error message.
163fn format_failures(failures: &[FileFailure]) -> String {
164 let mut s = String::new();
165 for f in failures {
166 s.push_str("\n - ");
167 s.push_str(f.filename.as_str());
168 s.push_str(": ");
169 s.push_str(f.reason.as_str());
170 }
171 s
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn unsupported_format_error_lists_all_four_formats() {
180 // v0.10.3 Phase B commit 7: the `UnsupportedInspectFormat` wording
181 // names every format the inspect dispatcher handles — .safetensors,
182 // .npz, .gguf, and .pth, all remote (via the `HttpRangeReader`
183 // adapter, since v0.11.0/v0.11.1/v0.11.2/v0.11.4 respectively) or
184 // cached.
185 let e = FetchError::UnsupportedInspectFormat {
186 filename: "weights.pt".to_owned(),
187 extension: "pt".to_owned(),
188 };
189 let msg = e.to_string();
190 for ext in [".safetensors", ".gguf", ".npz", ".pth"] {
191 assert!(msg.contains(ext), "Display message missing {ext}: {msg}");
192 }
193 // Sanity: the unrecognised extension and filename are still surfaced.
194 assert!(
195 msg.contains(".pt"),
196 "should name the offending extension: {msg}"
197 );
198 assert!(
199 msg.contains("weights.pt"),
200 "should name the filename: {msg}"
201 );
202 }
203}