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