Skip to main content

ai_agents_recovery/
error.rs

1//! Recovery error types
2
3use super::ErrorType;
4use std::fmt;
5use thiserror::Error;
6
7#[derive(Debug, Clone)]
8pub struct ClassifiedError {
9    pub error_type: ErrorType,
10    pub message: String,
11    pub retryable: bool,
12}
13
14impl ClassifiedError {
15    pub fn new(error_type: ErrorType, message: impl Into<String>) -> Self {
16        let retryable = matches!(
17            error_type,
18            ErrorType::Timeout
19                | ErrorType::RateLimit
20                | ErrorType::ConnectionError
21                | ErrorType::ServerError
22        );
23        Self {
24            error_type,
25            message: message.into(),
26            retryable,
27        }
28    }
29
30    pub fn timeout(message: impl Into<String>) -> Self {
31        Self::new(ErrorType::Timeout, message)
32    }
33
34    pub fn rate_limit(message: impl Into<String>) -> Self {
35        Self::new(ErrorType::RateLimit, message)
36    }
37
38    pub fn connection(message: impl Into<String>) -> Self {
39        Self::new(ErrorType::ConnectionError, message)
40    }
41
42    pub fn server(message: impl Into<String>) -> Self {
43        Self::new(ErrorType::ServerError, message)
44    }
45
46    pub fn invalid_api_key(message: impl Into<String>) -> Self {
47        Self::new(ErrorType::InvalidApiKey, message)
48    }
49
50    pub fn context_too_long(message: impl Into<String>) -> Self {
51        Self::new(ErrorType::ContextTooLong, message)
52    }
53
54    pub fn tool_error(message: impl Into<String>) -> Self {
55        Self::new(ErrorType::ToolError, message)
56    }
57}
58
59impl fmt::Display for ClassifiedError {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        write!(f, "[{:?}] {}", self.error_type, self.message)
62    }
63}
64
65impl std::error::Error for ClassifiedError {}
66
67#[derive(Debug, Error)]
68pub enum RecoveryError {
69    #[error("Retry limit exceeded after {attempts} attempts: {last_error}")]
70    MaxRetriesExceeded {
71        attempts: u32,
72        last_error: ClassifiedError,
73    },
74
75    #[error("Non-retryable error: {0}")]
76    NonRetryable(ClassifiedError),
77
78    #[error("Circuit breaker open for: {resource}")]
79    CircuitOpen { resource: String },
80
81    #[error("Timeout after {duration_ms}ms")]
82    Timeout { duration_ms: u64 },
83
84    #[error("No fallback available: {0}")]
85    NoFallback(String),
86
87    #[error("{0}")]
88    Other(String),
89}
90
91impl RecoveryError {
92    pub fn is_retryable(&self) -> bool {
93        matches!(self, RecoveryError::Timeout { .. })
94    }
95
96    pub fn last_error(&self) -> Option<&ClassifiedError> {
97        match self {
98            RecoveryError::MaxRetriesExceeded { last_error, .. } => Some(last_error),
99            RecoveryError::NonRetryable(e) => Some(e),
100            _ => None,
101        }
102    }
103}
104
105/// Typed failure from a retry loop that preserves the operation's original error.
106///
107/// Callers can stop terminal protocol failures immediately while retaining the distinction
108/// between policy non-retryability and retry exhaustion for their existing fallback logic.
109#[derive(Debug)]
110pub enum RetryFailure<E> {
111    /// The caller declared this exact error terminal before retry classification or backoff.
112    Terminal { attempts: u32, error: E },
113    /// Retry policy rejected this error type on the current attempt.
114    NonRetryable {
115        attempts: u32,
116        error: E,
117        classified: ClassifiedError,
118    },
119    /// The initial attempt and all configured retries failed.
120    Exhausted {
121        attempts: u32,
122        error: E,
123        classified: ClassifiedError,
124    },
125}
126
127impl<E> RetryFailure<E> {
128    /// Returns the number of attempts completed before this failure.
129    pub fn attempts(&self) -> u32 {
130        match self {
131            Self::Terminal { attempts, .. }
132            | Self::NonRetryable { attempts, .. }
133            | Self::Exhausted { attempts, .. } => *attempts,
134        }
135    }
136
137    /// Returns whether the caller explicitly classified this failure as terminal.
138    pub fn is_terminal(&self) -> bool {
139        matches!(self, Self::Terminal { .. })
140    }
141
142    /// Borrows the original typed operation error.
143    pub fn error(&self) -> &E {
144        match self {
145            Self::Terminal { error, .. }
146            | Self::NonRetryable { error, .. }
147            | Self::Exhausted { error, .. } => error,
148        }
149    }
150
151    /// Returns retry classification evidence when policy evaluated the error.
152    pub fn classified(&self) -> Option<&ClassifiedError> {
153        match self {
154            Self::Terminal { .. } => None,
155            Self::NonRetryable { classified, .. } | Self::Exhausted { classified, .. } => {
156                Some(classified)
157            }
158        }
159    }
160
161    /// Consumes the failure and returns the original typed operation error.
162    pub fn into_error(self) -> E {
163        match self {
164            Self::Terminal { error, .. }
165            | Self::NonRetryable { error, .. }
166            | Self::Exhausted { error, .. } => error,
167        }
168    }
169}
170
171impl<E: fmt::Display> fmt::Display for RetryFailure<E> {
172    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
173        match self {
174            Self::Terminal { attempts, error } => {
175                write!(
176                    formatter,
177                    "Terminal error after {attempts} attempt(s): {error}"
178                )
179            }
180            Self::NonRetryable {
181                attempts, error, ..
182            } => write!(
183                formatter,
184                "Non-retryable error after {attempts} attempt(s): {error}"
185            ),
186            Self::Exhausted {
187                attempts, error, ..
188            } => write!(
189                formatter,
190                "Retry limit exceeded after {attempts} attempt(s): {error}"
191            ),
192        }
193    }
194}
195
196impl<E: std::error::Error + 'static> std::error::Error for RetryFailure<E> {}
197
198pub trait IntoClassifiedError {
199    fn classify(self) -> ClassifiedError;
200}
201
202impl IntoClassifiedError for ClassifiedError {
203    fn classify(self) -> ClassifiedError {
204        self
205    }
206}
207
208impl IntoClassifiedError for ai_agents_llm::LLMError {
209    fn classify(self) -> ClassifiedError {
210        classify_llm_error(&self)
211    }
212}
213
214/// Classifies an LLM error by reference so typed retry paths can retain the original value.
215pub fn classify_llm_error(error: &ai_agents_llm::LLMError) -> ClassifiedError {
216    match error {
217        ai_agents_llm::LLMError::RateLimit { .. } => ClassifiedError::rate_limit(error.to_string()),
218        ai_agents_llm::LLMError::Network(_) => ClassifiedError::connection(error.to_string()),
219        ai_agents_llm::LLMError::API { status, .. } => {
220            if let Some(code) = status {
221                if *code >= 500 {
222                    return ClassifiedError::server(error.to_string());
223                }
224                if *code == 401 || *code == 403 {
225                    return ClassifiedError::invalid_api_key(error.to_string());
226                }
227            }
228            ClassifiedError::new(ErrorType::InvalidRequest, error.to_string())
229        }
230        ai_agents_llm::LLMError::Config(_) => {
231            ClassifiedError::new(ErrorType::InvalidRequest, error.to_string())
232        }
233        _ => ClassifiedError::new(ErrorType::InvalidResponse, error.to_string()),
234    }
235}
236
237impl IntoClassifiedError for ai_agents_core::AgentError {
238    fn classify(self) -> ClassifiedError {
239        match &self {
240            ai_agents_core::AgentError::Tool(msg) => ClassifiedError::tool_error(msg),
241            ai_agents_core::AgentError::LLM(msg) | ai_agents_core::AgentError::LLMError(msg) => {
242                if msg.to_lowercase().contains("timeout") {
243                    ClassifiedError::timeout(msg)
244                } else if msg.to_lowercase().contains("rate") {
245                    ClassifiedError::rate_limit(msg)
246                } else {
247                    ClassifiedError::new(ErrorType::InvalidResponse, msg)
248                }
249            }
250            _ => ClassifiedError::new(ErrorType::InvalidRequest, self.to_string()),
251        }
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn test_classified_error() {
261        let err = ClassifiedError::timeout("request timed out");
262        assert!(err.retryable);
263        assert_eq!(err.error_type, ErrorType::Timeout);
264    }
265
266    #[test]
267    fn test_non_retryable() {
268        let err = ClassifiedError::invalid_api_key("bad key");
269        assert!(!err.retryable);
270    }
271}