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