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    /// Binary task capacity (queued, active, or callback pending) is exhausted.
57    BinaryTaskQueueFull = 123,
58    /// A binary response exceeded its configured in-memory body limit.
59    BinaryBodyTooLarge = 124,
60}
61
62/// Library error type returned by most public APIs.
63#[derive(Debug, Clone)]
64pub struct MeowError {
65    /// Numeric error code, usually mapped from [`InnerErrorCode`].
66    code: i32,
67    /// Human-readable error message.
68    msg: String,
69    /// Optional chained source error.
70    source: Option<Arc<dyn StdError + Send + Sync>>,
71    /// Optional HTTP status code, set when the error was produced from a
72    /// non-success HTTP response. Lets the retry layer distinguish a
73    /// non-retryable client error (4xx) from a transient server error (5xx).
74    http_status: Option<u16>,
75}
76
77impl MeowError {
78    /// Creates a new error with raw numeric code and message.
79    ///
80    /// # Examples
81    ///
82    /// ```no_run
83    /// use rusty_cat::api::MeowError;
84    ///
85    /// let err = MeowError::new(9999, "custom failure".to_string());
86    /// assert_eq!(err.code(), 9999);
87    /// ```
88    pub fn new(code: i32, msg: String) -> Self {
89        crate::log::emit_lazy(|| {
90            crate::log::Log::debug(
91                "error",
92                format!(
93                    "MeowError::new code={} msg={}",
94                    code,
95                    crate::log::redact_secrets(&msg)
96                ),
97            )
98        });
99        MeowError {
100            code,
101            msg,
102            source: None,
103            http_status: None,
104        }
105    }
106
107    /// Returns numeric error code.
108    ///
109    /// # Examples
110    ///
111    /// ```no_run
112    /// use rusty_cat::api::{InnerErrorCode, MeowError};
113    ///
114    /// let err = MeowError::from_code1(InnerErrorCode::ClientClosed);
115    /// assert_eq!(err.code(), InnerErrorCode::ClientClosed as i32);
116    /// ```
117    pub fn code(&self) -> i32 {
118        self.code
119    }
120
121    /// Returns the error message as a borrowed `&str`.
122    ///
123    /// Borrowing avoids an allocation on every call; callers that need an
124    /// owned `String` can do `err.msg().to_owned()` explicitly.
125    ///
126    /// # Examples
127    ///
128    /// ```no_run
129    /// use rusty_cat::api::{InnerErrorCode, MeowError};
130    ///
131    /// let err = MeowError::from_code_str(InnerErrorCode::InvalidRange, "bad range");
132    /// assert_eq!(err.msg(), "bad range");
133    /// ```
134    pub fn msg(&self) -> &str {
135        &self.msg
136    }
137
138    /// Returns the HTTP status code when this error came from a non-success HTTP
139    /// response, or `None` otherwise.
140    ///
141    /// # Examples
142    ///
143    /// ```no_run
144    /// use rusty_cat::api::{InnerErrorCode, MeowError};
145    ///
146    /// let err = MeowError::from_code_str(InnerErrorCode::ParameterEmpty, "bad");
147    /// assert_eq!(err.http_status(), None);
148    /// ```
149    pub fn http_status(&self) -> Option<u16> {
150        self.http_status
151    }
152
153    /// Attaches the originating HTTP status code, returning the updated error.
154    ///
155    /// Used by transport code that turns a non-success HTTP response into a
156    /// [`MeowError`], so the retry layer can fast-fail non-retryable client
157    /// errors (4xx) while still retrying transient server errors (5xx).
158    pub(crate) fn with_http_status(mut self, status: u16) -> Self {
159        self.http_status = Some(status);
160        self
161    }
162
163    /// Creates an error from [`InnerErrorCode`] with empty message.
164    ///
165    /// # Examples
166    ///
167    /// ```no_run
168    /// use rusty_cat::api::{InnerErrorCode, MeowError};
169    ///
170    /// let err = MeowError::from_code1(InnerErrorCode::ParameterEmpty);
171    /// assert_eq!(err.code(), InnerErrorCode::ParameterEmpty as i32);
172    /// ```
173    pub fn from_code1(code: InnerErrorCode) -> Self {
174        crate::log::emit_lazy(|| {
175            crate::log::Log::debug("error", format!("MeowError::from_code1 code={:?}", code))
176        });
177        MeowError {
178            code: code as i32,
179            msg: String::new(),
180            source: None,
181            http_status: None,
182        }
183    }
184
185    /// Creates an error from [`InnerErrorCode`] and message.
186    ///
187    /// # Examples
188    ///
189    /// ```no_run
190    /// use rusty_cat::api::{InnerErrorCode, MeowError};
191    ///
192    /// let err = MeowError::from_code(InnerErrorCode::EnqueueError, "enqueue failed".to_string());
193    /// assert_eq!(err.code(), InnerErrorCode::EnqueueError as i32);
194    /// ```
195    pub fn from_code(code: InnerErrorCode, msg: String) -> Self {
196        crate::log::emit_lazy(|| {
197            crate::log::Log::debug(
198                "error",
199                format!(
200                    "MeowError::from_code code={:?} msg={}",
201                    code,
202                    crate::log::redact_secrets(&msg)
203                ),
204            )
205        });
206        MeowError {
207            code: code as i32,
208            msg,
209            source: None,
210            http_status: None,
211        }
212    }
213
214    /// Creates an error from [`InnerErrorCode`] and `&str` message.
215    ///
216    /// # Examples
217    ///
218    /// ```no_run
219    /// use rusty_cat::api::{InnerErrorCode, MeowError};
220    ///
221    /// let err = MeowError::from_code_str(InnerErrorCode::TaskNotFound, "unknown id");
222    /// assert_eq!(err.code(), InnerErrorCode::TaskNotFound as i32);
223    /// ```
224    pub fn from_code_str(code: InnerErrorCode, msg: &str) -> Self {
225        crate::log::emit_lazy(|| {
226            crate::log::Log::debug(
227                "error",
228                format!(
229                    "MeowError::from_code_str code={:?} msg={}",
230                    code,
231                    crate::log::redact_secrets(msg)
232                ),
233            )
234        });
235        MeowError {
236            code: code as i32,
237            msg: msg.to_string(),
238            source: None,
239            http_status: None,
240        }
241    }
242
243    /// Creates an error with source chaining.
244    ///
245    /// Use this helper to preserve original low-level errors.
246    ///
247    /// # Examples
248    ///
249    /// ```no_run
250    /// use rusty_cat::api::{InnerErrorCode, MeowError};
251    ///
252    /// let source = std::io::Error::other("disk error");
253    /// let err = MeowError::from_source(InnerErrorCode::IoError, "upload failed", source);
254    /// assert_eq!(err.code(), InnerErrorCode::IoError as i32);
255    /// ```
256    pub fn from_source<E>(code: InnerErrorCode, msg: impl Into<String>, source: E) -> Self
257    where
258        E: StdError + Send + Sync + 'static,
259    {
260        let msg = msg.into();
261        let source_preview = source.to_string();
262        crate::log::emit_lazy(|| {
263            crate::log::Log::debug(
264                "error",
265                format!(
266                    "MeowError::from_source code={:?} msg={} source={}",
267                    code,
268                    crate::log::redact_secrets(&msg),
269                    crate::log::redact_secrets(&source_preview)
270                ),
271            )
272        });
273        MeowError {
274            code: code as i32,
275            msg,
276            source: Some(Arc::new(source)),
277            http_status: None,
278        }
279    }
280
281    /// Creates an error from a local I/O error, automatically classifying
282    /// common failure modes into more specific codes:
283    ///
284    /// - out-of-space (`ENOSPC` on Unix / `ERROR_DISK_FULL` on Windows) maps to
285    ///   [`InnerErrorCode::DiskFull`];
286    /// - a missing target (`std::io::ErrorKind::NotFound`) maps to
287    ///   [`InnerErrorCode::LocalFileRemoved`], which is what surfaces when a
288    ///   source/target file is deleted while a transfer is running;
289    /// - anything else falls back to [`InnerErrorCode::IoError`].
290    ///
291    /// The original [`std::io::Error`] is preserved in the error source chain so
292    /// callers can still inspect `raw_os_error()` / `kind()` if needed.
293    ///
294    /// # Examples
295    ///
296    /// ```no_run
297    /// use rusty_cat::api::{InnerErrorCode, MeowError};
298    ///
299    /// let not_found = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
300    /// let err = MeowError::from_io("download file missing", not_found);
301    /// assert_eq!(err.code(), InnerErrorCode::LocalFileRemoved as i32);
302    /// ```
303    pub fn from_io(msg: impl Into<String>, source: std::io::Error) -> Self {
304        let code = classify_io_error(&source);
305        Self::from_source(code, msg, source)
306    }
307}
308
309/// Classifies a local I/O error into the most specific SDK error code.
310///
311/// Used by [`MeowError::from_io`]; kept as a standalone function so the mapping
312/// can be unit-tested in isolation.
313pub(crate) fn classify_io_error(e: &std::io::Error) -> InnerErrorCode {
314    if is_disk_full(e) {
315        InnerErrorCode::DiskFull
316    } else if e.kind() == std::io::ErrorKind::NotFound {
317        InnerErrorCode::LocalFileRemoved
318    } else {
319        InnerErrorCode::IoError
320    }
321}
322
323/// Detects "no space left on device" across platforms via raw OS error codes.
324///
325/// `std::io::ErrorKind` does not expose a stable out-of-space variant across the
326/// toolchains this crate targets, so the raw OS error number is checked instead:
327/// `ENOSPC` (28) on Unix-like systems, and `ERROR_DISK_FULL` (112) /
328/// `ERROR_HANDLE_DISK_FULL` (39) on Windows.
329fn is_disk_full(e: &std::io::Error) -> bool {
330    if let Some(code) = e.raw_os_error() {
331        #[cfg(unix)]
332        if code == 28 {
333            return true;
334        }
335        #[cfg(windows)]
336        if code == 112 || code == 39 {
337            return true;
338        }
339        let _ = code;
340    }
341    false
342}
343
344impl PartialEq for MeowError {
345    fn eq(&self, other: &Self) -> bool {
346        self.code == other.code && self.msg == other.msg
347    }
348}
349
350impl Eq for MeowError {}
351
352impl Display for MeowError {
353    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
354        if self.msg.is_empty() {
355            write!(f, "MeowError(code={})", self.code)
356        } else {
357            write!(f, "MeowError(code={}, msg={})", self.code, self.msg)
358        }
359    }
360}
361
362impl StdError for MeowError {
363    fn source(&self) -> Option<&(dyn StdError + 'static)> {
364        self.source
365            .as_deref()
366            .map(|e| e as &(dyn StdError + 'static))
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::{InnerErrorCode, MeowError};
373
374    #[test]
375    fn meow_error_display_contains_code_and_message() {
376        let err = MeowError::from_code_str(InnerErrorCode::InvalidRange, "bad range");
377        let s = format!("{err}");
378        assert!(s.contains("code="));
379        assert!(s.contains("bad range"));
380    }
381
382    #[test]
383    fn meow_error_source_is_accessible() {
384        let io = std::io::Error::other("disk io");
385        let err = MeowError::from_source(InnerErrorCode::IoError, "io failed", io);
386        assert!(std::error::Error::source(&err).is_some());
387    }
388
389    #[test]
390    fn from_io_classifies_not_found_as_local_file_removed() {
391        let not_found = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
392        let err = MeowError::from_io("target file missing", not_found);
393        assert_eq!(err.code(), InnerErrorCode::LocalFileRemoved as i32);
394        // Original io error is preserved for callers that want to inspect it.
395        assert!(std::error::Error::source(&err).is_some());
396    }
397
398    #[test]
399    fn from_io_classifies_generic_error_as_io_error() {
400        let denied = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
401        let err = MeowError::from_io("write failed", denied);
402        assert_eq!(err.code(), InnerErrorCode::IoError as i32);
403    }
404
405    #[cfg(any(unix, windows))]
406    #[test]
407    fn from_io_classifies_out_of_space_as_disk_full() {
408        // ENOSPC on Unix, ERROR_DISK_FULL on Windows.
409        #[cfg(unix)]
410        let full = std::io::Error::from_raw_os_error(28);
411        #[cfg(windows)]
412        let full = std::io::Error::from_raw_os_error(112);
413
414        let err = MeowError::from_io("write download file failed", full);
415        assert_eq!(err.code(), InnerErrorCode::DiskFull as i32);
416    }
417
418    #[cfg(windows)]
419    #[test]
420    fn from_io_classifies_handle_disk_full_as_disk_full() {
421        // ERROR_HANDLE_DISK_FULL (39) is the other Windows out-of-space code.
422        let full = std::io::Error::from_raw_os_error(39);
423        let err = MeowError::from_io("write download file failed", full);
424        assert_eq!(err.code(), InnerErrorCode::DiskFull as i32);
425    }
426}