Skip to main content

rusty_cat/
error.rs

1use std::error::Error as StdError;
2use std::fmt::{Display, Formatter};
3use std::sync::Arc;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum InnerErrorCode {
7    /// Unknown/unclassified error.
8    Unknown = -1,
9    /// Success (non-error sentinel).
10    Success = 0,
11    /// Runtime creation failed.
12    RuntimeCreationFailedError = 101,
13    /// Required parameter is empty or invalid.
14    ParameterEmpty = 102,
15    /// The same file/task is already queued or running.
16    DuplicateTaskError = 103,
17    /// Failed to enqueue task.
18    EnqueueError = 104,
19    /// Local I/O operation failed.
20    IoError = 105,
21    /// HTTP request/response operation failed.
22    HttpError = 106,
23    /// Client has already been closed and can no longer accept operations.
24    ClientClosed = 107,
25    /// Unknown task ID in control API.
26    TaskNotFound = 108,
27    /// HTTP response status is not expected.
28    ResponseStatusError = 109,
29    /// `Content-Length` from HEAD is missing or invalid.
30    MissingOrInvalidContentLengthFromHead = 110,
31    /// Failed to send command to scheduler thread.
32    CommandSendFailed = 111,
33    /// Command response channel closed unexpectedly.
34    CommandResponseFailed = 112,
35    /// Failed to parse response payload (for example JSON).
36    ResponseParseError = 113,
37    /// Invalid HTTP range semantics or headers.
38    InvalidRange = 114,
39    /// Local file does not exist.
40    FileNotFound = 115,
41    /// File checksum/signature does not match expected value.
42    ChecksumMismatch = 116,
43    /// Current task state does not allow requested operation.
44    InvalidTaskState = 117,
45    /// Internal lock is poisoned.
46    LockPoisoned = 118,
47    /// Failed to build internal HTTP client.
48    HttpClientBuildFailed = 119,
49    /// Task was canceled before reaching `Complete`.
50    TaskCanceled = 120,
51    /// Local disk ran out of space (`ENOSPC` / `ERROR_DISK_FULL`).
52    DiskFull = 121,
53    /// Local source/target file was removed or replaced while a transfer was
54    /// in progress (for example the user deleted it mid-download).
55    LocalFileRemoved = 122,
56}
57
58/// Library error type returned by most public APIs.
59#[derive(Debug, Clone)]
60pub struct MeowError {
61    /// Numeric error code, usually mapped from [`InnerErrorCode`].
62    code: i32,
63    /// Human-readable error message.
64    msg: String,
65    /// Optional chained source error.
66    source: Option<Arc<dyn StdError + Send + Sync>>,
67    /// Optional HTTP status code, set when the error was produced from a
68    /// non-success HTTP response. Lets the retry layer distinguish a
69    /// non-retryable client error (4xx) from a transient server error (5xx).
70    http_status: Option<u16>,
71}
72
73impl MeowError {
74    /// Creates a new error with raw numeric code and message.
75    ///
76    /// # Examples
77    ///
78    /// ```no_run
79    /// use rusty_cat::api::MeowError;
80    ///
81    /// let err = MeowError::new(9999, "custom failure".to_string());
82    /// assert_eq!(err.code(), 9999);
83    /// ```
84    pub fn new(code: i32, msg: String) -> Self {
85        crate::log::emit_lazy(|| {
86            crate::log::Log::debug("error", format!("MeowError::new code={} msg={}", code, msg))
87        });
88        MeowError {
89            code,
90            msg,
91            source: None,
92            http_status: None,
93        }
94    }
95
96    /// Returns numeric error code.
97    ///
98    /// # Examples
99    ///
100    /// ```no_run
101    /// use rusty_cat::api::{InnerErrorCode, MeowError};
102    ///
103    /// let err = MeowError::from_code1(InnerErrorCode::ClientClosed);
104    /// assert_eq!(err.code(), InnerErrorCode::ClientClosed as i32);
105    /// ```
106    pub fn code(&self) -> i32 {
107        self.code
108    }
109
110    /// Returns the error message as a borrowed `&str`.
111    ///
112    /// Borrowing avoids an allocation on every call; callers that need an
113    /// owned `String` can do `err.msg().to_owned()` explicitly.
114    ///
115    /// # Examples
116    ///
117    /// ```no_run
118    /// use rusty_cat::api::{InnerErrorCode, MeowError};
119    ///
120    /// let err = MeowError::from_code_str(InnerErrorCode::InvalidRange, "bad range");
121    /// assert_eq!(err.msg(), "bad range");
122    /// ```
123    pub fn msg(&self) -> &str {
124        &self.msg
125    }
126
127    /// Returns the HTTP status code when this error came from a non-success HTTP
128    /// response, or `None` otherwise.
129    ///
130    /// # Examples
131    ///
132    /// ```no_run
133    /// use rusty_cat::api::{InnerErrorCode, MeowError};
134    ///
135    /// let err = MeowError::from_code_str(InnerErrorCode::ParameterEmpty, "bad");
136    /// assert_eq!(err.http_status(), None);
137    /// ```
138    pub fn http_status(&self) -> Option<u16> {
139        self.http_status
140    }
141
142    /// Attaches the originating HTTP status code, returning the updated error.
143    ///
144    /// Used by transport code that turns a non-success HTTP response into a
145    /// [`MeowError`], so the retry layer can fast-fail non-retryable client
146    /// errors (4xx) while still retrying transient server errors (5xx).
147    pub(crate) fn with_http_status(mut self, status: u16) -> Self {
148        self.http_status = Some(status);
149        self
150    }
151
152    /// Creates an error from [`InnerErrorCode`] with empty message.
153    ///
154    /// # Examples
155    ///
156    /// ```no_run
157    /// use rusty_cat::api::{InnerErrorCode, MeowError};
158    ///
159    /// let err = MeowError::from_code1(InnerErrorCode::ParameterEmpty);
160    /// assert_eq!(err.code(), InnerErrorCode::ParameterEmpty as i32);
161    /// ```
162    pub fn from_code1(code: InnerErrorCode) -> Self {
163        crate::log::emit_lazy(|| {
164            crate::log::Log::debug("error", format!("MeowError::from_code1 code={:?}", code))
165        });
166        MeowError {
167            code: code as i32,
168            msg: String::new(),
169            source: None,
170            http_status: None,
171        }
172    }
173
174    /// Creates an error from [`InnerErrorCode`] and message.
175    ///
176    /// # Examples
177    ///
178    /// ```no_run
179    /// use rusty_cat::api::{InnerErrorCode, MeowError};
180    ///
181    /// let err = MeowError::from_code(InnerErrorCode::EnqueueError, "enqueue failed".to_string());
182    /// assert_eq!(err.code(), InnerErrorCode::EnqueueError as i32);
183    /// ```
184    pub fn from_code(code: InnerErrorCode, msg: String) -> Self {
185        crate::log::emit_lazy(|| {
186            crate::log::Log::debug(
187                "error",
188                format!("MeowError::from_code code={:?} msg={}", code, msg),
189            )
190        });
191        MeowError {
192            code: code as i32,
193            msg,
194            source: None,
195            http_status: None,
196        }
197    }
198
199    /// Creates an error from [`InnerErrorCode`] and `&str` message.
200    ///
201    /// # Examples
202    ///
203    /// ```no_run
204    /// use rusty_cat::api::{InnerErrorCode, MeowError};
205    ///
206    /// let err = MeowError::from_code_str(InnerErrorCode::TaskNotFound, "unknown id");
207    /// assert_eq!(err.code(), InnerErrorCode::TaskNotFound as i32);
208    /// ```
209    pub fn from_code_str(code: InnerErrorCode, msg: &str) -> Self {
210        crate::log::emit_lazy(|| {
211            crate::log::Log::debug(
212                "error",
213                format!("MeowError::from_code_str code={:?} msg={}", code, msg),
214            )
215        });
216        MeowError {
217            code: code as i32,
218            msg: msg.to_string(),
219            source: None,
220            http_status: None,
221        }
222    }
223
224    /// Creates an error with source chaining.
225    ///
226    /// Use this helper to preserve original low-level errors.
227    ///
228    /// # Examples
229    ///
230    /// ```no_run
231    /// use rusty_cat::api::{InnerErrorCode, MeowError};
232    ///
233    /// let source = std::io::Error::other("disk error");
234    /// let err = MeowError::from_source(InnerErrorCode::IoError, "upload failed", source);
235    /// assert_eq!(err.code(), InnerErrorCode::IoError as i32);
236    /// ```
237    pub fn from_source<E>(code: InnerErrorCode, msg: impl Into<String>, source: E) -> Self
238    where
239        E: StdError + Send + Sync + 'static,
240    {
241        let msg = msg.into();
242        let source_preview = source.to_string();
243        crate::log::emit_lazy(|| {
244            crate::log::Log::debug(
245                "error",
246                format!(
247                    "MeowError::from_source code={:?} msg={} source={}",
248                    code, msg, source_preview
249                ),
250            )
251        });
252        MeowError {
253            code: code as i32,
254            msg,
255            source: Some(Arc::new(source)),
256            http_status: None,
257        }
258    }
259
260    /// Creates an error from a local I/O error, automatically classifying
261    /// common failure modes into more specific codes:
262    ///
263    /// - out-of-space (`ENOSPC` on Unix / `ERROR_DISK_FULL` on Windows) maps to
264    ///   [`InnerErrorCode::DiskFull`];
265    /// - a missing target (`std::io::ErrorKind::NotFound`) maps to
266    ///   [`InnerErrorCode::LocalFileRemoved`], which is what surfaces when a
267    ///   source/target file is deleted while a transfer is running;
268    /// - anything else falls back to [`InnerErrorCode::IoError`].
269    ///
270    /// The original [`std::io::Error`] is preserved in the error source chain so
271    /// callers can still inspect `raw_os_error()` / `kind()` if needed.
272    ///
273    /// # Examples
274    ///
275    /// ```no_run
276    /// use rusty_cat::api::{InnerErrorCode, MeowError};
277    ///
278    /// let not_found = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
279    /// let err = MeowError::from_io("download file missing", not_found);
280    /// assert_eq!(err.code(), InnerErrorCode::LocalFileRemoved as i32);
281    /// ```
282    pub fn from_io(msg: impl Into<String>, source: std::io::Error) -> Self {
283        let code = classify_io_error(&source);
284        Self::from_source(code, msg, source)
285    }
286}
287
288/// Classifies a local I/O error into the most specific SDK error code.
289///
290/// Used by [`MeowError::from_io`]; kept as a standalone function so the mapping
291/// can be unit-tested in isolation.
292pub(crate) fn classify_io_error(e: &std::io::Error) -> InnerErrorCode {
293    if is_disk_full(e) {
294        InnerErrorCode::DiskFull
295    } else if e.kind() == std::io::ErrorKind::NotFound {
296        InnerErrorCode::LocalFileRemoved
297    } else {
298        InnerErrorCode::IoError
299    }
300}
301
302/// Detects "no space left on device" across platforms via raw OS error codes.
303///
304/// `std::io::ErrorKind` does not expose a stable out-of-space variant across the
305/// toolchains this crate targets, so the raw OS error number is checked instead:
306/// `ENOSPC` (28) on Unix-like systems, and `ERROR_DISK_FULL` (112) /
307/// `ERROR_HANDLE_DISK_FULL` (39) on Windows.
308fn is_disk_full(e: &std::io::Error) -> bool {
309    if let Some(code) = e.raw_os_error() {
310        #[cfg(unix)]
311        if code == 28 {
312            return true;
313        }
314        #[cfg(windows)]
315        if code == 112 || code == 39 {
316            return true;
317        }
318        let _ = code;
319    }
320    false
321}
322
323impl PartialEq for MeowError {
324    fn eq(&self, other: &Self) -> bool {
325        self.code == other.code && self.msg == other.msg
326    }
327}
328
329impl Eq for MeowError {}
330
331impl Display for MeowError {
332    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
333        if self.msg.is_empty() {
334            write!(f, "MeowError(code={})", self.code)
335        } else {
336            write!(f, "MeowError(code={}, msg={})", self.code, self.msg)
337        }
338    }
339}
340
341impl StdError for MeowError {
342    fn source(&self) -> Option<&(dyn StdError + 'static)> {
343        self.source
344            .as_deref()
345            .map(|e| e as &(dyn StdError + 'static))
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::{InnerErrorCode, MeowError};
352
353    #[test]
354    fn meow_error_display_contains_code_and_message() {
355        let err = MeowError::from_code_str(InnerErrorCode::InvalidRange, "bad range");
356        let s = format!("{err}");
357        assert!(s.contains("code="));
358        assert!(s.contains("bad range"));
359    }
360
361    #[test]
362    fn meow_error_source_is_accessible() {
363        let io = std::io::Error::other("disk io");
364        let err = MeowError::from_source(InnerErrorCode::IoError, "io failed", io);
365        assert!(std::error::Error::source(&err).is_some());
366    }
367
368    #[test]
369    fn from_io_classifies_not_found_as_local_file_removed() {
370        let not_found = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
371        let err = MeowError::from_io("target file missing", not_found);
372        assert_eq!(err.code(), InnerErrorCode::LocalFileRemoved as i32);
373        // Original io error is preserved for callers that want to inspect it.
374        assert!(std::error::Error::source(&err).is_some());
375    }
376
377    #[test]
378    fn from_io_classifies_generic_error_as_io_error() {
379        let denied = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
380        let err = MeowError::from_io("write failed", denied);
381        assert_eq!(err.code(), InnerErrorCode::IoError as i32);
382    }
383
384    #[cfg(any(unix, windows))]
385    #[test]
386    fn from_io_classifies_out_of_space_as_disk_full() {
387        // ENOSPC on Unix, ERROR_DISK_FULL on Windows.
388        #[cfg(unix)]
389        let full = std::io::Error::from_raw_os_error(28);
390        #[cfg(windows)]
391        let full = std::io::Error::from_raw_os_error(112);
392
393        let err = MeowError::from_io("write download file failed", full);
394        assert_eq!(err.code(), InnerErrorCode::DiskFull as i32);
395    }
396
397    #[cfg(windows)]
398    #[test]
399    fn from_io_classifies_handle_disk_full_as_disk_full() {
400        // ERROR_HANDLE_DISK_FULL (39) is the other Windows out-of-space code.
401        let full = std::io::Error::from_raw_os_error(39);
402        let err = MeowError::from_io("write download file failed", full);
403        assert_eq!(err.code(), InnerErrorCode::DiskFull as i32);
404    }
405}