Skip to main content

tt_shared/
error.rs

1//! Error types returned by provider adapters. The core layer maps these to
2//! HTTP status codes and decides retry strategy — adapters do not retry.
3
4use thiserror::Error;
5
6#[derive(Debug, Error)]
7pub enum ProviderError {
8    #[error("unauthorized: {0}")]
9    Unauthorized(String),
10
11    #[error("rate limited (retry after {retry_after_ms} ms)")]
12    RateLimited { retry_after_ms: u64 },
13
14    #[error("model not found: {model}")]
15    ModelNotFound { model: String },
16
17    #[error("invalid request: {0}")]
18    InvalidRequest(String),
19
20    #[error("upstream provider error (status {status}): {message}")]
21    ProviderUpstream { status: u16, message: String },
22
23    #[error("timeout after {ms} ms")]
24    Timeout { ms: u64 },
25
26    #[error("network error: {0}")]
27    Network(#[from] reqwest::Error),
28
29    #[error("deserialize error: {0}")]
30    Deserialize(String),
31
32    #[error("unsupported feature: {0}")]
33    Unsupported(String),
34
35    #[error("internal error: {0}")]
36    Internal(String),
37}
38
39impl ProviderError {
40    /// True if the error is retriable. The core layer applies backoff + jitter.
41    pub fn is_retriable(&self) -> bool {
42        match self {
43            ProviderError::RateLimited { .. } => true,
44            ProviderError::Timeout { .. } => true,
45            ProviderError::Network(_) => true,
46            ProviderError::ProviderUpstream { status, .. } => *status >= 500,
47            _ => false,
48        }
49    }
50
51    /// True if the error means we should try a fallback provider.
52    ///
53    /// Only upstream *server* errors (5xx) are fallback-eligible. A
54    /// deterministic client error (400 invalid request, 403 forbidden, 422
55    /// unprocessable) will fail identically on every provider, so failing
56    /// over just burns extra upstream calls + spend. Matches the `>= 500`
57    /// guard in [`Self::is_retriable`]. (429 maps to [`Self::RateLimited`]
58    /// and timeouts to [`Self::Timeout`], which are handled separately.)
59    pub fn is_fallback_eligible(&self) -> bool {
60        match self {
61            ProviderError::ModelNotFound { .. } => true,
62            ProviderError::Timeout { .. } => true,
63            ProviderError::ProviderUpstream { status, .. } => *status >= 500,
64            _ => false,
65        }
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn upstream_5xx_is_fallback_eligible() {
75        assert!(ProviderError::ProviderUpstream {
76            status: 500,
77            message: "boom".into()
78        }
79        .is_fallback_eligible());
80        assert!(ProviderError::ProviderUpstream {
81            status: 503,
82            message: "unavailable".into()
83        }
84        .is_fallback_eligible());
85    }
86
87    #[test]
88    fn upstream_4xx_is_not_fallback_eligible() {
89        for status in [400u16, 403, 404, 422] {
90            assert!(
91                !ProviderError::ProviderUpstream {
92                    status,
93                    message: "client error".into()
94                }
95                .is_fallback_eligible(),
96                "status {status} must not fail over"
97            );
98        }
99    }
100
101    #[test]
102    fn model_not_found_and_timeout_still_fallback_eligible() {
103        assert!(ProviderError::ModelNotFound { model: "x".into() }.is_fallback_eligible());
104        assert!(ProviderError::Timeout { ms: 1000 }.is_fallback_eligible());
105    }
106
107    #[test]
108    fn invalid_request_and_unauthorized_not_fallback_eligible() {
109        assert!(!ProviderError::InvalidRequest("bad".into()).is_fallback_eligible());
110        assert!(!ProviderError::Unauthorized("nope".into()).is_fallback_eligible());
111    }
112}