Skip to main content

axon/
backend_error.rs

1//! Backend Error Classification — structured error types for LLM API calls.
2//!
3//! Provides `BackendErrorKind` enum that classifies API errors into categories
4//! enabling retry/circuit-breaker decisions:
5//!   - Retryable: Timeout, RateLimit, ServerError (5xx), NetworkError, StreamDropped
6//!   - Non-retryable: AuthError, InvalidResponse, ProviderUnavailable
7//!
8//! Used by `resilient_backend.rs` to determine whether to retry a failed call.
9
10use std::time::Duration;
11
12/// Classification of backend API errors.
13#[derive(Debug, Clone)]
14pub enum BackendErrorKind {
15    /// Request timed out (connect or read timeout).
16    Timeout,
17    /// Provider returned 429 Too Many Requests.
18    RateLimit {
19        /// Hint from the provider's Retry-After header, if present.
20        retry_after: Option<Duration>,
21    },
22    /// Provider returned a server error (5xx).
23    ServerError { status: u16 },
24    /// Authentication failed (401/403) — API key invalid or expired.
25    AuthError,
26    /// Network-level error (DNS, connection refused, TLS, etc.).
27    NetworkError,
28    /// SSE stream dropped mid-response.
29    StreamDropped,
30    /// Provider response could not be parsed.
31    InvalidResponse,
32    /// Provider is unknown or not registered.
33    ProviderUnavailable,
34    /// Circuit breaker is open — calls are being rejected.
35    CircuitOpen,
36    /// Unclassified error.
37    Unknown,
38}
39
40impl BackendErrorKind {
41    /// Whether this error type is worth retrying.
42    pub fn is_retryable(&self) -> bool {
43        matches!(
44            self,
45            BackendErrorKind::Timeout
46                | BackendErrorKind::RateLimit { .. }
47                | BackendErrorKind::ServerError { .. }
48                | BackendErrorKind::NetworkError
49                | BackendErrorKind::StreamDropped
50        )
51    }
52
53    /// Human-readable error category for logging.
54    pub fn category(&self) -> &'static str {
55        match self {
56            BackendErrorKind::Timeout => "timeout",
57            BackendErrorKind::RateLimit { .. } => "rate_limit",
58            BackendErrorKind::ServerError { .. } => "server_error",
59            BackendErrorKind::AuthError => "auth_error",
60            BackendErrorKind::NetworkError => "network_error",
61            BackendErrorKind::StreamDropped => "stream_dropped",
62            BackendErrorKind::InvalidResponse => "invalid_response",
63            BackendErrorKind::ProviderUnavailable => "provider_unavailable",
64            BackendErrorKind::CircuitOpen => "circuit_open",
65            BackendErrorKind::Unknown => "unknown",
66        }
67    }
68
69    /// Classify an HTTP status code into an error kind.
70    pub fn from_status(status: u16) -> Self {
71        match status {
72            401 | 403 => BackendErrorKind::AuthError,
73            429 => BackendErrorKind::RateLimit { retry_after: None },
74            408 => BackendErrorKind::Timeout,
75            s if s >= 500 => BackendErrorKind::ServerError { status: s },
76            _ => BackendErrorKind::Unknown,
77        }
78    }
79
80    /// Classify a reqwest error into an error kind.
81    pub fn from_reqwest_error(e: &reqwest::Error) -> Self {
82        if e.is_timeout() {
83            BackendErrorKind::Timeout
84        } else if e.is_connect() {
85            BackendErrorKind::NetworkError
86        } else if e.is_request() {
87            BackendErrorKind::NetworkError
88        } else if let Some(status) = e.status() {
89            BackendErrorKind::from_status(status.as_u16())
90        } else {
91            BackendErrorKind::NetworkError
92        }
93    }
94}
95
96impl std::fmt::Display for BackendErrorKind {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        write!(f, "{}", self.category())
99    }
100}
101
102// ── Tests ──────────────────────────────────────────────────────────────────
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn test_retryable_errors() {
110        assert!(BackendErrorKind::Timeout.is_retryable());
111        assert!(BackendErrorKind::RateLimit { retry_after: None }.is_retryable());
112        assert!(BackendErrorKind::ServerError { status: 500 }.is_retryable());
113        assert!(BackendErrorKind::ServerError { status: 503 }.is_retryable());
114        assert!(BackendErrorKind::NetworkError.is_retryable());
115        assert!(BackendErrorKind::StreamDropped.is_retryable());
116    }
117
118    #[test]
119    fn test_non_retryable_errors() {
120        assert!(!BackendErrorKind::AuthError.is_retryable());
121        assert!(!BackendErrorKind::InvalidResponse.is_retryable());
122        assert!(!BackendErrorKind::ProviderUnavailable.is_retryable());
123        assert!(!BackendErrorKind::CircuitOpen.is_retryable());
124        assert!(!BackendErrorKind::Unknown.is_retryable());
125    }
126
127    #[test]
128    fn test_from_status_classification() {
129        assert!(matches!(BackendErrorKind::from_status(401), BackendErrorKind::AuthError));
130        assert!(matches!(BackendErrorKind::from_status(403), BackendErrorKind::AuthError));
131        assert!(matches!(BackendErrorKind::from_status(429), BackendErrorKind::RateLimit { .. }));
132        assert!(matches!(BackendErrorKind::from_status(408), BackendErrorKind::Timeout));
133        assert!(matches!(BackendErrorKind::from_status(500), BackendErrorKind::ServerError { status: 500 }));
134        assert!(matches!(BackendErrorKind::from_status(502), BackendErrorKind::ServerError { status: 502 }));
135        assert!(matches!(BackendErrorKind::from_status(503), BackendErrorKind::ServerError { status: 503 }));
136        assert!(matches!(BackendErrorKind::from_status(400), BackendErrorKind::Unknown));
137        assert!(matches!(BackendErrorKind::from_status(404), BackendErrorKind::Unknown));
138    }
139
140    #[test]
141    fn test_category_strings() {
142        assert_eq!(BackendErrorKind::Timeout.category(), "timeout");
143        assert_eq!(BackendErrorKind::RateLimit { retry_after: None }.category(), "rate_limit");
144        assert_eq!(BackendErrorKind::AuthError.category(), "auth_error");
145        assert_eq!(BackendErrorKind::CircuitOpen.category(), "circuit_open");
146    }
147
148    #[test]
149    fn test_display() {
150        assert_eq!(format!("{}", BackendErrorKind::Timeout), "timeout");
151        assert_eq!(format!("{}", BackendErrorKind::NetworkError), "network_error");
152    }
153}