Skip to main content

threads_rs/
error.rs

1use std::time::Duration;
2
3/// Alias for `std::result::Result<T, Error>`.
4pub type Result<T> = std::result::Result<T, Error>;
5
6/// All error variants returned by the Threads API client.
7#[derive(Debug, thiserror::Error)]
8pub enum Error {
9    /// An authentication or authorization failure (e.g. invalid/expired token).
10    #[error("authentication error {code} ({error_type}): {message}")]
11    Authentication {
12        /// API error code.
13        code: u16,
14        /// Human-readable error message.
15        message: String,
16        /// Machine-readable error category.
17        error_type: String,
18        /// Additional detail string from the API.
19        details: String,
20        /// Whether the API flagged this error as transient.
21        is_transient: bool,
22        /// HTTP status code of the response.
23        http_status_code: u16,
24        /// API error sub-code for finer classification.
25        error_subcode: u16,
26    },
27
28    /// The request was throttled by the API rate limiter.
29    #[error("rate limit error {code}: {message}")]
30    RateLimit {
31        /// API error code.
32        code: u16,
33        /// Human-readable error message.
34        message: String,
35        /// Machine-readable error category.
36        error_type: String,
37        /// Additional detail string from the API.
38        details: String,
39        /// Suggested wait time before retrying.
40        retry_after: Duration,
41        /// Whether the API flagged this error as transient.
42        is_transient: bool,
43        /// HTTP status code of the response.
44        http_status_code: u16,
45        /// API error sub-code for finer classification.
46        error_subcode: u16,
47    },
48
49    /// A request parameter or payload failed validation.
50    #[error("validation error {code}: {message}")]
51    Validation {
52        /// API error code.
53        code: u16,
54        /// Human-readable error message.
55        message: String,
56        /// Machine-readable error category.
57        error_type: String,
58        /// Additional detail string from the API.
59        details: String,
60        /// Name of the invalid field.
61        field: String,
62        /// Whether the API flagged this error as transient.
63        is_transient: bool,
64        /// HTTP status code of the response.
65        http_status_code: u16,
66        /// API error sub-code for finer classification.
67        error_subcode: u16,
68    },
69
70    /// A transport-level failure (DNS, TCP, TLS, timeout, etc.).
71    #[error("network error: {message}")]
72    Network {
73        /// API error code.
74        code: u16,
75        /// Human-readable error message.
76        message: String,
77        /// Machine-readable error category.
78        error_type: String,
79        /// Additional detail string from the API.
80        details: String,
81        /// Whether the failure appears to be temporary.
82        temporary: bool,
83        /// Whether the API flagged this error as transient.
84        is_transient: bool,
85        /// HTTP status code of the response.
86        http_status_code: u16,
87        /// API error sub-code for finer classification.
88        error_subcode: u16,
89        /// Optional underlying reqwest error.
90        #[source]
91        cause: Option<reqwest::Error>,
92    },
93
94    /// A generic API error that does not fit a more specific variant.
95    #[error("API error {code}: {message}")]
96    Api {
97        /// API error code.
98        code: u16,
99        /// Human-readable error message.
100        message: String,
101        /// Machine-readable error category.
102        error_type: String,
103        /// Additional detail string from the API.
104        details: String,
105        /// Server-assigned request identifier for support/debugging.
106        request_id: String,
107        /// Whether the API flagged this error as transient.
108        is_transient: bool,
109        /// HTTP status code of the response.
110        http_status_code: u16,
111        /// API error sub-code for finer classification.
112        error_subcode: u16,
113    },
114
115    /// An HTTP-level error from the underlying reqwest client.
116    #[error("HTTP error: {0}")]
117    Http(#[from] reqwest::Error),
118
119    /// A JSON serialization or deserialization error.
120    #[error("JSON error: {0}")]
121    Json(#[from] serde_json::Error),
122}
123
124// ---------------------------------------------------------------------------
125// Constructors
126// ---------------------------------------------------------------------------
127
128/// Create a new authentication error.
129pub fn new_authentication_error(code: u16, message: &str, details: &str) -> Error {
130    Error::Authentication {
131        code,
132        message: message.to_owned(),
133        error_type: "authentication_error".to_owned(),
134        details: details.to_owned(),
135        is_transient: false,
136        http_status_code: 0,
137        error_subcode: 0,
138    }
139}
140
141/// Create a new rate-limit error.
142pub fn new_rate_limit_error(
143    code: u16,
144    message: &str,
145    details: &str,
146    retry_after: Duration,
147) -> Error {
148    Error::RateLimit {
149        code,
150        message: message.to_owned(),
151        error_type: "rate_limit_error".to_owned(),
152        details: details.to_owned(),
153        retry_after,
154        is_transient: false,
155        http_status_code: 0,
156        error_subcode: 0,
157    }
158}
159
160/// Create a new validation error.
161pub fn new_validation_error(code: u16, message: &str, details: &str, field: &str) -> Error {
162    Error::Validation {
163        code,
164        message: message.to_owned(),
165        error_type: "validation_error".to_owned(),
166        details: details.to_owned(),
167        field: field.to_owned(),
168        is_transient: false,
169        http_status_code: 0,
170        error_subcode: 0,
171    }
172}
173
174/// Create a new network error without an underlying cause.
175pub fn new_network_error(code: u16, message: &str, details: &str, temporary: bool) -> Error {
176    new_network_error_with_cause(code, message, details, temporary, None)
177}
178
179/// Create a new network error wrapping an underlying reqwest cause.
180pub fn new_network_error_with_cause(
181    code: u16,
182    message: &str,
183    details: &str,
184    temporary: bool,
185    cause: Option<reqwest::Error>,
186) -> Error {
187    Error::Network {
188        code,
189        message: message.to_owned(),
190        error_type: "network_error".to_owned(),
191        details: details.to_owned(),
192        temporary,
193        is_transient: false,
194        http_status_code: 0,
195        error_subcode: 0,
196        cause,
197    }
198}
199
200/// Create a new generic API error.
201pub fn new_api_error(code: u16, message: &str, details: &str, request_id: &str) -> Error {
202    Error::Api {
203        code,
204        message: message.to_owned(),
205        error_type: "api_error".to_owned(),
206        details: details.to_owned(),
207        request_id: request_id.to_owned(),
208        is_transient: false,
209        http_status_code: 0,
210        error_subcode: 0,
211    }
212}
213
214// ---------------------------------------------------------------------------
215// Metadata helpers
216// ---------------------------------------------------------------------------
217
218/// Base fields extracted from any typed error variant.
219pub struct BaseFields<'a> {
220    /// API error code.
221    pub code: u16,
222    /// Human-readable error message.
223    pub message: &'a str,
224    /// Machine-readable error category.
225    pub error_type: &'a str,
226    /// Additional detail string from the API.
227    pub details: &'a str,
228    /// Whether the API flagged this error as transient.
229    pub is_transient: bool,
230    /// HTTP status code of the response.
231    pub http_status_code: u16,
232    /// API error sub-code for finer classification.
233    pub error_subcode: u16,
234}
235
236/// Extract common base fields from a typed error variant.
237/// Returns `None` for `Http` and `Json` variants.
238pub fn extract_base_fields(err: &Error) -> Option<BaseFields<'_>> {
239    match err {
240        Error::Authentication {
241            code,
242            message,
243            error_type,
244            details,
245            is_transient,
246            http_status_code,
247            error_subcode,
248        } => Some(BaseFields {
249            code: *code,
250            message,
251            error_type,
252            details,
253            is_transient: *is_transient,
254            http_status_code: *http_status_code,
255            error_subcode: *error_subcode,
256        }),
257        Error::RateLimit {
258            code,
259            message,
260            error_type,
261            details,
262            is_transient,
263            http_status_code,
264            error_subcode,
265            ..
266        } => Some(BaseFields {
267            code: *code,
268            message,
269            error_type,
270            details,
271            is_transient: *is_transient,
272            http_status_code: *http_status_code,
273            error_subcode: *error_subcode,
274        }),
275        Error::Validation {
276            code,
277            message,
278            error_type,
279            details,
280            is_transient,
281            http_status_code,
282            error_subcode,
283            ..
284        } => Some(BaseFields {
285            code: *code,
286            message,
287            error_type,
288            details,
289            is_transient: *is_transient,
290            http_status_code: *http_status_code,
291            error_subcode: *error_subcode,
292        }),
293        Error::Network {
294            code,
295            message,
296            error_type,
297            details,
298            is_transient,
299            http_status_code,
300            error_subcode,
301            ..
302        } => Some(BaseFields {
303            code: *code,
304            message,
305            error_type,
306            details,
307            is_transient: *is_transient,
308            http_status_code: *http_status_code,
309            error_subcode: *error_subcode,
310        }),
311        Error::Api {
312            code,
313            message,
314            error_type,
315            details,
316            is_transient,
317            http_status_code,
318            error_subcode,
319            ..
320        } => Some(BaseFields {
321            code: *code,
322            message,
323            error_type,
324            details,
325            is_transient: *is_transient,
326            http_status_code: *http_status_code,
327            error_subcode: *error_subcode,
328        }),
329        Error::Http(_) | Error::Json(_) => None,
330    }
331}
332
333/// Set transient flag, HTTP status code, and error subcode on a typed error.
334pub fn set_error_metadata(
335    err: &mut Error,
336    is_transient: bool,
337    http_status_code: u16,
338    error_subcode: u16,
339) {
340    match err {
341        Error::Authentication {
342            is_transient: t,
343            http_status_code: h,
344            error_subcode: s,
345            ..
346        }
347        | Error::RateLimit {
348            is_transient: t,
349            http_status_code: h,
350            error_subcode: s,
351            ..
352        }
353        | Error::Validation {
354            is_transient: t,
355            http_status_code: h,
356            error_subcode: s,
357            ..
358        }
359        | Error::Network {
360            is_transient: t,
361            http_status_code: h,
362            error_subcode: s,
363            ..
364        }
365        | Error::Api {
366            is_transient: t,
367            http_status_code: h,
368            error_subcode: s,
369            ..
370        } => {
371            *t = is_transient;
372            *h = http_status_code;
373            *s = error_subcode;
374        }
375        Error::Http(_) | Error::Json(_) => {}
376    }
377}
378
379// ---------------------------------------------------------------------------
380// Type-checking helpers
381// ---------------------------------------------------------------------------
382
383impl Error {
384    /// Returns `true` if this is an authentication error.
385    pub fn is_authentication(&self) -> bool {
386        matches!(self, Error::Authentication { .. })
387    }
388
389    /// Returns `true` if this is a rate-limit error.
390    pub fn is_rate_limit(&self) -> bool {
391        matches!(self, Error::RateLimit { .. })
392    }
393
394    /// Returns `true` if this is a validation error.
395    pub fn is_validation(&self) -> bool {
396        matches!(self, Error::Validation { .. })
397    }
398
399    /// Returns `true` if this is a network error.
400    pub fn is_network(&self) -> bool {
401        matches!(self, Error::Network { .. })
402    }
403
404    /// Returns `true` if this is a generic API error.
405    pub fn is_api(&self) -> bool {
406        matches!(self, Error::Api { .. })
407    }
408
409    /// Returns `true` if the API flagged this error as transient.
410    pub fn is_transient(&self) -> bool {
411        match self {
412            Error::Authentication { is_transient, .. }
413            | Error::RateLimit { is_transient, .. }
414            | Error::Validation { is_transient, .. }
415            | Error::Network { is_transient, .. }
416            | Error::Api { is_transient, .. } => *is_transient,
417            _ => false,
418        }
419    }
420
421    /// Returns `true` if the request can be retried (rate-limit, transient, or temporary network).
422    pub fn is_retryable(&self) -> bool {
423        match self {
424            Error::RateLimit { .. } => true,
425            Error::Network {
426                temporary,
427                is_transient,
428                ..
429            } => *temporary || *is_transient,
430            _ => self.is_transient(),
431        }
432    }
433}
434
435// ---------------------------------------------------------------------------
436// Tests
437// ---------------------------------------------------------------------------
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn test_new_authentication_error() {
445        let err = new_authentication_error(401, "Invalid token", "Token expired");
446        assert!(err.is_authentication());
447        assert!(!err.is_rate_limit());
448        assert!(!err.is_transient());
449        assert!(!err.is_retryable());
450        assert!(err.to_string().contains("401"));
451        assert!(err.to_string().contains("Invalid token"));
452    }
453
454    #[test]
455    fn test_new_rate_limit_error() {
456        let err = new_rate_limit_error(429, "Too many requests", "", Duration::from_secs(60));
457        assert!(err.is_rate_limit());
458        assert!(!err.is_authentication());
459        assert!(err.is_retryable());
460    }
461
462    #[test]
463    fn test_new_validation_error() {
464        let err = new_validation_error(400, "Bad input", "text too long", "text");
465        assert!(err.is_validation());
466        assert!(!err.is_retryable());
467    }
468
469    #[test]
470    fn test_new_network_error() {
471        let err = new_network_error(0, "Connection refused", "", true);
472        assert!(err.is_network());
473        assert!(err.is_retryable());
474    }
475
476    #[test]
477    fn test_new_network_error_not_temporary() {
478        let err = new_network_error(0, "DNS failure", "", false);
479        assert!(err.is_network());
480        assert!(!err.is_retryable());
481    }
482
483    #[test]
484    fn test_network_error_transient_is_retryable() {
485        let mut err = new_network_error(0, "Transient failure", "", false);
486        assert!(!err.is_retryable());
487        set_error_metadata(&mut err, true, 503, 0);
488        assert!(err.is_transient());
489        assert!(err.is_retryable());
490    }
491
492    #[test]
493    fn test_new_api_error() {
494        let err = new_api_error(500, "Internal error", "", "req-123");
495        assert!(err.is_api());
496        assert!(!err.is_retryable());
497    }
498
499    #[test]
500    fn test_set_error_metadata() {
501        let mut err = new_api_error(500, "Internal error", "", "");
502        assert!(!err.is_transient());
503        set_error_metadata(&mut err, true, 503, 42);
504        assert!(err.is_transient());
505        assert!(err.is_retryable());
506        let base = extract_base_fields(&err).unwrap();
507        assert_eq!(base.http_status_code, 503);
508        assert_eq!(base.error_subcode, 42);
509    }
510
511    #[test]
512    fn test_extract_base_fields_http() {
513        // Http and Json variants should return None
514        let err = Error::Json(serde_json::from_str::<String>("invalid").unwrap_err());
515        assert!(extract_base_fields(&err).is_none());
516    }
517
518    #[test]
519    fn test_is_helpers_exhaustive() {
520        let auth = new_authentication_error(401, "x", "");
521        assert!(auth.is_authentication());
522        assert!(!auth.is_rate_limit());
523        assert!(!auth.is_validation());
524        assert!(!auth.is_network());
525        assert!(!auth.is_api());
526
527        let rate = new_rate_limit_error(429, "x", "", Duration::from_secs(1));
528        assert!(!rate.is_authentication());
529        assert!(rate.is_rate_limit());
530
531        let val = new_validation_error(400, "x", "", "f");
532        assert!(val.is_validation());
533
534        let net = new_network_error(0, "x", "", false);
535        assert!(net.is_network());
536
537        let api = new_api_error(500, "x", "", "");
538        assert!(api.is_api());
539    }
540}