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}