Skip to main content

finance_query/
error.rs

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