Skip to main content

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}