rust_research_mcp/
error.rs

1use std::time::Duration;
2use thiserror::Error;
3
4/// Comprehensive error categorization for resilience framework
5#[derive(Error, Debug)]
6pub enum Error {
7    // Configuration errors (permanent failures)
8    #[error("Configuration error: {0}")]
9    Config(#[from] config::ConfigError),
10
11    // I/O errors (potentially transient)
12    #[error("IO error: {0}")]
13    Io(#[from] std::io::Error),
14
15    // Serialization errors (usually permanent)
16    #[error("Serialization error: {0}")]
17    Serde(#[from] serde_json::Error),
18
19    // Network errors (transient - should retry)
20    #[error("HTTP error: {0}")]
21    Http(#[from] reqwest::Error),
22
23    #[error("Network timeout after {timeout:?}: {message}")]
24    NetworkTimeout { timeout: Duration, message: String },
25
26    #[error("Connection refused: {endpoint}")]
27    ConnectionRefused { endpoint: String },
28
29    #[error("DNS resolution failed: {hostname}")]
30    DnsFailure { hostname: String },
31
32    // Service-specific errors
33    #[error("MCP protocol error: {0}")]
34    Mcp(String),
35
36    #[error("Sci-Hub service error: {code} - {message}")]
37    SciHub { code: u16, message: String },
38
39    #[error("Rate limit exceeded: retry after {retry_after:?}")]
40    RateLimitExceeded { retry_after: Duration },
41
42    // Client errors (permanent - don't retry)
43    #[error("Invalid input: {field} - {reason}")]
44    InvalidInput { field: String, reason: String },
45
46    #[error("Authentication failed: {0}")]
47    AuthenticationFailed(String),
48
49    #[error("Authorization denied: {resource}")]
50    AuthorizationDenied { resource: String },
51
52    // Server errors (transient - should retry)
53    #[error("Service temporarily unavailable: {service} - {reason}")]
54    ServiceUnavailable { service: String, reason: String },
55
56    #[error("Internal server error: {0}")]
57    InternalServerError(String),
58
59    #[error("Service overloaded: {service}")]
60    ServiceOverloaded { service: String },
61
62    // Circuit breaker errors
63    #[error("Circuit breaker open for service: {service}")]
64    CircuitBreakerOpen { service: String },
65
66    #[error("Circuit breaker half-open, limited requests allowed")]
67    CircuitBreakerHalfOpen,
68
69    // Resource errors
70    #[error("Resource exhausted: {resource} - {current}/{limit}")]
71    ResourceExhausted {
72        resource: String,
73        current: u64,
74        limit: u64,
75    },
76
77    #[error("Timeout error: operation timed out after {timeout:?}")]
78    Timeout { timeout: Duration },
79
80    // Cache errors
81    #[error("Cache error: {operation} failed - {reason}")]
82    Cache { operation: String, reason: String },
83
84    // Parse errors
85    #[error("Parse error in {context}: {message}")]
86    Parse { context: String, message: String },
87
88    // General service error
89    #[error("Service error: {0}")]
90    Service(String),
91
92    // Provider errors
93    #[error("Provider error: {0}")]
94    Provider(String),
95}
96
97/// Error categorization for retry strategies
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum ErrorCategory {
100    /// Permanent errors - should not retry
101    Permanent,
102    /// Transient errors - safe to retry
103    Transient,
104    /// Rate limited - retry with backoff
105    RateLimited,
106    /// Circuit breaker triggered - stop retrying temporarily
107    CircuitBreaker,
108}
109
110impl Error {
111    /// Categorize error for retry logic
112    #[must_use]
113    pub const fn category(&self) -> ErrorCategory {
114        match self {
115            // Permanent errors - don't retry
116            Self::Config(_)
117            | Self::InvalidInput { .. }
118            | Self::AuthenticationFailed(_)
119            | Self::AuthorizationDenied { .. }
120            | Self::Parse { .. }
121            | Self::Serde(_) => ErrorCategory::Permanent,
122
123            // Rate limited - retry with backoff
124            Self::RateLimitExceeded { .. } => ErrorCategory::RateLimited,
125
126            // Circuit breaker errors
127            Self::CircuitBreakerOpen { .. } | Self::CircuitBreakerHalfOpen => {
128                ErrorCategory::CircuitBreaker
129            }
130
131            // Transient errors - retry with exponential backoff
132            Self::Http(_)
133            | Self::NetworkTimeout { .. }
134            | Self::ConnectionRefused { .. }
135            | Self::DnsFailure { .. }
136            | Self::ServiceUnavailable { .. }
137            | Self::InternalServerError(_)
138            | Self::ServiceOverloaded { .. }
139            | Self::Timeout { .. }
140            | Self::Io(_) => ErrorCategory::Transient,
141
142            // Service specific - depends on context
143            Self::SciHub { code, .. } => {
144                match *code {
145                    // Rate limiting (handle first to avoid unreachable pattern)
146                    429 => ErrorCategory::RateLimited,
147                    // 403 Forbidden for Sci-Hub should be treated as transient (mirror blocking)
148                    403 => ErrorCategory::Transient,
149                    // Other 4xx client errors are permanent (except 403)
150                    400..=499 => ErrorCategory::Permanent,
151                    // 5xx server errors are transient
152                    500..=599 => ErrorCategory::Transient,
153                    // Other codes treated as transient
154                    _ => ErrorCategory::Transient,
155                }
156            }
157
158            // Provider errors - categorize based on the error type
159            Self::Provider(_) => ErrorCategory::Transient,
160
161            // Default to transient for unknown errors
162            _ => ErrorCategory::Transient,
163        }
164    }
165
166    /// Check if error is retryable
167    #[must_use]
168    pub const fn is_retryable(&self) -> bool {
169        matches!(
170            self.category(),
171            ErrorCategory::Transient | ErrorCategory::RateLimited
172        )
173    }
174
175    /// Get suggested retry delay for rate limited errors
176    #[must_use]
177    pub const fn retry_after(&self) -> Option<Duration> {
178        match self {
179            Self::RateLimitExceeded { retry_after } => Some(*retry_after),
180            _ => None,
181        }
182    }
183
184    /// Check if error indicates a need for circuit breaker
185    #[must_use]
186    pub const fn should_trigger_circuit_breaker(&self) -> bool {
187        matches!(
188            self,
189            Self::ServiceUnavailable { .. }
190                | Self::InternalServerError(_)
191                | Self::ServiceOverloaded { .. }
192                | Self::NetworkTimeout { .. }
193                | Self::ConnectionRefused { .. }
194        )
195    }
196}
197
198pub type Result<T> = std::result::Result<T, Error>;
199
200// Provider error conversion
201impl From<crate::client::providers::ProviderError> for Error {
202    fn from(err: crate::client::providers::ProviderError) -> Self {
203        match err {
204            crate::client::providers::ProviderError::Network(msg) => {
205                Self::Provider(format!("Network error: {msg}"))
206            }
207            crate::client::providers::ProviderError::Parse(msg) => Self::Parse {
208                context: "provider".to_string(),
209                message: msg,
210            },
211            crate::client::providers::ProviderError::RateLimit => Self::RateLimitExceeded {
212                retry_after: Duration::from_secs(60),
213            },
214            crate::client::providers::ProviderError::Auth(msg) => Self::AuthenticationFailed(msg),
215            crate::client::providers::ProviderError::InvalidQuery(msg) => Self::InvalidInput {
216                field: "query".to_string(),
217                reason: msg,
218            },
219            crate::client::providers::ProviderError::ServiceUnavailable(msg) => {
220                Self::ServiceUnavailable {
221                    service: "provider".to_string(),
222                    reason: msg,
223                }
224            }
225            crate::client::providers::ProviderError::Timeout => Self::Timeout {
226                timeout: Duration::from_secs(30),
227            },
228            crate::client::providers::ProviderError::Other(msg) => Self::Provider(msg),
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn test_error_categorization() {
239        // Permanent errors
240        assert_eq!(
241            Error::InvalidInput {
242                field: "test".to_string(),
243                reason: "test".to_string()
244            }
245            .category(),
246            ErrorCategory::Permanent
247        );
248
249        // Transient errors
250        assert_eq!(
251            Error::NetworkTimeout {
252                timeout: Duration::from_secs(30),
253                message: "test".to_string()
254            }
255            .category(),
256            ErrorCategory::Transient
257        );
258
259        // Rate limited errors
260        assert_eq!(
261            Error::RateLimitExceeded {
262                retry_after: Duration::from_secs(60)
263            }
264            .category(),
265            ErrorCategory::RateLimited
266        );
267
268        // Sci-Hub specific errors - updated for 403 handling
269        assert_eq!(
270            Error::SciHub {
271                code: 403,
272                message: "Forbidden".to_string()
273            }
274            .category(),
275            ErrorCategory::Transient // Changed: 403 is now transient for Sci-Hub
276        );
277
278        assert_eq!(
279            Error::SciHub {
280                code: 429,
281                message: "Too Many Requests".to_string()
282            }
283            .category(),
284            ErrorCategory::RateLimited
285        );
286
287        assert_eq!(
288            Error::SciHub {
289                code: 500,
290                message: "Internal Server Error".to_string()
291            }
292            .category(),
293            ErrorCategory::Transient
294        );
295
296        // Other 4xx errors should still be permanent
297        assert_eq!(
298            Error::SciHub {
299                code: 404,
300                message: "Not Found".to_string()
301            }
302            .category(),
303            ErrorCategory::Permanent
304        );
305    }
306
307    #[test]
308    fn test_retry_logic() {
309        let transient_error = Error::NetworkTimeout {
310            timeout: Duration::from_secs(30),
311            message: "test".to_string(),
312        };
313        assert!(transient_error.is_retryable());
314
315        let permanent_error = Error::InvalidInput {
316            field: "test".to_string(),
317            reason: "test".to_string(),
318        };
319        assert!(!permanent_error.is_retryable());
320
321        let rate_limited_error = Error::RateLimitExceeded {
322            retry_after: Duration::from_secs(60),
323        };
324        assert!(rate_limited_error.is_retryable());
325        assert_eq!(
326            rate_limited_error.retry_after(),
327            Some(Duration::from_secs(60))
328        );
329
330        // Test that Sci-Hub 403 errors are now retryable
331        let scihub_403_error = Error::SciHub {
332            code: 403,
333            message: "Forbidden".to_string(),
334        };
335        assert!(scihub_403_error.is_retryable());
336    }
337}