Skip to main content

finance_query/
error.rs

1use crate::providers::{Capability, Operation, Provider};
2use thiserror::Error;
3
4fn join_providers(providers: &[Provider]) -> String {
5    providers
6        .iter()
7        .map(|p| p.as_str())
8        .collect::<Vec<_>>()
9        .join(", ")
10}
11
12/// Main error type for the library
13#[derive(Error, Debug)]
14pub enum FinanceError {
15    /// Authentication failed (Yahoo Finance, SEC EDGAR, etc.)
16    #[error("Authentication failed: {context}")]
17    AuthenticationFailed {
18        /// Error context
19        context: String,
20    },
21
22    /// The requested symbol was not found
23    #[error("Symbol not found: {}", symbol.as_deref().unwrap_or("unknown"))]
24    SymbolNotFound {
25        /// The symbol that was not found
26        symbol: Option<String>,
27        /// Additional context
28        context: String,
29    },
30
31    /// Rate limit exceeded
32    #[error("Rate limited (retry after {retry_after:?}s)")]
33    RateLimited {
34        /// Seconds until retry is allowed
35        retry_after: Option<u64>,
36    },
37
38    /// HTTP request error
39    #[error("HTTP request failed: {0}")]
40    HttpError(#[from] reqwest::Error),
41
42    /// Failed to parse JSON response
43    #[error("JSON parse error: {0}")]
44    JsonParseError(#[from] serde_json::Error),
45
46    /// Response structure error - missing or malformed fields
47    #[error("Response structure error in '{field}': {context}")]
48    ResponseStructureError {
49        /// Field name that caused the error
50        field: String,
51        /// Error context
52        context: String,
53    },
54
55    /// Invalid parameter provided
56    #[error("Invalid parameter '{param}': {reason}")]
57    InvalidParameter {
58        /// Parameter name
59        param: String,
60        /// Reason for invalidity
61        reason: String,
62    },
63
64    /// Network timeout
65    #[error("Request timeout after {timeout_ms}ms")]
66    Timeout {
67        /// Timeout duration in milliseconds
68        timeout_ms: u64,
69    },
70
71    /// Server error (5xx status codes)
72    #[error("Server error {status}: {context}")]
73    ServerError {
74        /// HTTP status code
75        status: u16,
76        /// Error context
77        context: String,
78    },
79
80    /// Unexpected API response
81    #[error("Unexpected response: {0}")]
82    UnexpectedResponse(String),
83
84    /// Internal error
85    #[error("Internal error: {0}")]
86    InternalError(String),
87
88    /// General API error
89    #[error("API error: {0}")]
90    ApiError(String),
91
92    /// Tokio runtime error
93    #[error("Runtime error: {0}")]
94    RuntimeError(#[from] std::io::Error),
95
96    /// Indicator calculation error
97    #[cfg(feature = "indicators")]
98    #[error("Indicator calculation error: {0}")]
99    IndicatorError(#[from] crate::indicators::IndicatorError),
100
101    /// Error from an external (non-Yahoo) data API
102    #[error("External API error from '{api}': HTTP {status}")]
103    ExternalApiError {
104        /// Name of the external API (e.g., "alternative.me", "coingecko")
105        api: String,
106        /// HTTP status code returned
107        status: u16,
108    },
109
110    /// Error fetching or parsing macro-economic data (FRED, Treasury, BLS)
111    #[error("Macro data error from '{provider}': {context}")]
112    MacroDataError {
113        /// Provider name (e.g., "FRED", "US Treasury")
114        provider: String,
115        /// Error context
116        context: String,
117    },
118
119    /// Error parsing an RSS/Atom feed
120    #[error("Feed parse error for '{url}': {context}")]
121    FeedParseError {
122        /// Feed URL that failed
123        url: String,
124        /// Error context
125        context: String,
126    },
127
128    /// The requested operation is not supported by this provider.
129    #[error(
130        "{provider} does not support {operation} (supported by: {}; route it via Providers::builder().route(...))",
131        join_providers(candidates)
132    )]
133    NotSupported {
134        /// Provider identifier
135        provider: Provider,
136        /// The specific operation that isn't implemented
137        operation: Operation,
138        /// Other providers whose `capabilities()` declare this operation
139        /// (informational — may still need a feature flag enabled and/or routing).
140        candidates: Vec<Provider>,
141    },
142
143    /// No configured provider supports this operation or all providers failed.
144    #[error(
145        "no provider available for {operation} (supported by: {}; route it via Providers::builder().route(...))",
146        join_providers(candidates)
147    )]
148    NoProviderAvailable {
149        /// The capability no configured provider declared
150        operation: Capability,
151        /// Providers whose `capabilities()` declare this operation
152        /// (informational — may still need a feature flag enabled and/or routing).
153        candidates: Vec<Provider>,
154    },
155
156    /// Translation backend failure (model download, load, or inference)
157    #[cfg(feature = "translation")]
158    #[error("Translation error: {context}")]
159    TranslationError {
160        /// Error context
161        context: String,
162    },
163}
164
165/// Error category for logging and metrics
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum ErrorCategory {
168    /// Authentication errors
169    Auth,
170    /// Rate limiting errors
171    RateLimit,
172    /// Timeout errors
173    Timeout,
174    /// Server errors (5xx)
175    Server,
176    /// Not found errors
177    NotFound,
178    /// Validation errors
179    Validation,
180    /// Parsing errors
181    Parsing,
182    /// Other errors
183    Other,
184}
185
186/// Result type alias for library operations
187pub type Result<T> = std::result::Result<T, FinanceError>;
188
189impl FinanceError {
190    /// Check if this error is retriable
191    pub fn is_retriable(&self) -> bool {
192        matches!(
193            self,
194            FinanceError::Timeout { .. }
195                | FinanceError::RateLimited { .. }
196                | FinanceError::HttpError(_)
197                | FinanceError::AuthenticationFailed { .. }
198                | FinanceError::ServerError { .. }
199        ) || matches!(self, FinanceError::ExternalApiError { status, .. } if *status >= 500)
200    }
201
202    /// Check if this error indicates an authentication issue
203    pub fn is_auth_error(&self) -> bool {
204        matches!(self, FinanceError::AuthenticationFailed { .. })
205    }
206
207    /// Check if this error indicates a not found issue
208    pub fn is_not_found(&self) -> bool {
209        matches!(self, FinanceError::SymbolNotFound { .. })
210    }
211
212    /// Get retry delay in seconds (for exponential backoff)
213    pub fn retry_after_secs(&self) -> Option<u64> {
214        match self {
215            Self::RateLimited { retry_after } => *retry_after,
216            Self::Timeout { .. } => Some(2),
217            Self::ServerError { status, .. } if *status >= 500 => Some(5),
218            Self::AuthenticationFailed { .. } => Some(1),
219            _ => None,
220        }
221    }
222
223    /// Categorize errors for logging/metrics
224    pub fn category(&self) -> ErrorCategory {
225        match self {
226            Self::AuthenticationFailed { .. } => ErrorCategory::Auth,
227            Self::RateLimited { .. } => ErrorCategory::RateLimit,
228            Self::Timeout { .. } => ErrorCategory::Timeout,
229            Self::ServerError { .. } => ErrorCategory::Server,
230            Self::SymbolNotFound { .. } => ErrorCategory::NotFound,
231            Self::InvalidParameter { .. } => ErrorCategory::Validation,
232            Self::JsonParseError(_)
233            | Self::ResponseStructureError { .. }
234            | Self::MacroDataError { .. }
235            | Self::FeedParseError { .. } => ErrorCategory::Parsing,
236            Self::NotSupported { .. } | Self::NoProviderAvailable { .. } => {
237                ErrorCategory::Validation
238            }
239            Self::ExternalApiError { .. } => ErrorCategory::Server,
240            _ => ErrorCategory::Other,
241        }
242    }
243
244    /// Add symbol context to error (fluent API)
245    pub fn with_symbol(mut self, symbol: impl Into<String>) -> Self {
246        if let Self::SymbolNotFound {
247            symbol: ref mut s, ..
248        } = self
249        {
250            *s = Some(symbol.into());
251        }
252        self
253    }
254
255    /// Add context to error (fluent API)
256    pub fn with_context(mut self, context: impl Into<String>) -> Self {
257        match self {
258            Self::AuthenticationFailed {
259                context: ref mut c, ..
260            } => {
261                *c = context.into();
262            }
263            Self::SymbolNotFound {
264                context: ref mut c, ..
265            } => {
266                *c = context.into();
267            }
268            Self::ResponseStructureError {
269                context: ref mut c, ..
270            } => {
271                *c = context.into();
272            }
273            Self::ServerError {
274                context: ref mut c, ..
275            } => {
276                *c = context.into();
277            }
278            Self::MacroDataError {
279                context: ref mut c, ..
280            } => {
281                *c = context.into();
282            }
283            Self::FeedParseError {
284                context: ref mut c, ..
285            } => {
286                *c = context.into();
287            }
288            _ => {}
289        }
290        self
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn test_error_is_retriable() {
300        assert!(FinanceError::Timeout { timeout_ms: 5000 }.is_retriable());
301        assert!(FinanceError::RateLimited { retry_after: None }.is_retriable());
302        assert!(
303            FinanceError::AuthenticationFailed {
304                context: "test".to_string()
305            }
306            .is_retriable()
307        );
308        assert!(
309            FinanceError::ServerError {
310                status: 500,
311                context: "test".to_string()
312            }
313            .is_retriable()
314        );
315        assert!(
316            !FinanceError::SymbolNotFound {
317                symbol: Some("AAPL".to_string()),
318                context: "test".to_string()
319            }
320            .is_retriable()
321        );
322        assert!(
323            !FinanceError::InvalidParameter {
324                param: "test".to_string(),
325                reason: "invalid".to_string()
326            }
327            .is_retriable()
328        );
329    }
330
331    #[test]
332    fn test_error_is_auth_error() {
333        assert!(
334            FinanceError::AuthenticationFailed {
335                context: "test".to_string()
336            }
337            .is_auth_error()
338        );
339        assert!(!FinanceError::Timeout { timeout_ms: 5000 }.is_auth_error());
340    }
341
342    #[test]
343    fn test_error_is_not_found() {
344        assert!(
345            FinanceError::SymbolNotFound {
346                symbol: Some("AAPL".to_string()),
347                context: "test".to_string()
348            }
349            .is_not_found()
350        );
351        assert!(!FinanceError::Timeout { timeout_ms: 5000 }.is_not_found());
352    }
353
354    #[test]
355    fn test_retry_after_secs() {
356        assert_eq!(
357            FinanceError::RateLimited {
358                retry_after: Some(10)
359            }
360            .retry_after_secs(),
361            Some(10)
362        );
363        assert_eq!(
364            FinanceError::Timeout { timeout_ms: 5000 }.retry_after_secs(),
365            Some(2)
366        );
367        assert_eq!(
368            FinanceError::ServerError {
369                status: 503,
370                context: "test".to_string()
371            }
372            .retry_after_secs(),
373            Some(5)
374        );
375        assert_eq!(
376            FinanceError::SymbolNotFound {
377                symbol: None,
378                context: "test".to_string()
379            }
380            .retry_after_secs(),
381            None
382        );
383    }
384
385    #[test]
386    fn test_error_category() {
387        assert_eq!(
388            FinanceError::AuthenticationFailed {
389                context: "test".to_string()
390            }
391            .category(),
392            ErrorCategory::Auth
393        );
394        assert_eq!(
395            FinanceError::RateLimited { retry_after: None }.category(),
396            ErrorCategory::RateLimit
397        );
398        assert_eq!(
399            FinanceError::Timeout { timeout_ms: 5000 }.category(),
400            ErrorCategory::Timeout
401        );
402        assert_eq!(
403            FinanceError::SymbolNotFound {
404                symbol: None,
405                context: "test".to_string()
406            }
407            .category(),
408            ErrorCategory::NotFound
409        );
410    }
411
412    #[test]
413    fn test_with_symbol() {
414        let error = FinanceError::SymbolNotFound {
415            symbol: None,
416            context: "test".to_string(),
417        }
418        .with_symbol("AAPL");
419
420        if let FinanceError::SymbolNotFound { symbol, .. } = error {
421            assert_eq!(symbol, Some("AAPL".to_string()));
422        } else {
423            panic!("Expected SymbolNotFound");
424        }
425    }
426
427    #[test]
428    fn test_with_context() {
429        let error = FinanceError::AuthenticationFailed {
430            context: "old".to_string(),
431        }
432        .with_context("new context");
433
434        if let FinanceError::AuthenticationFailed { context } = error {
435            assert_eq!(context, "new context");
436        } else {
437            panic!("Expected AuthenticationFailed");
438        }
439    }
440}