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}