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