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    #[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}