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