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
136/// Error category for logging and metrics
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum ErrorCategory {
139    /// Authentication errors
140    Auth,
141    /// Rate limiting errors
142    RateLimit,
143    /// Timeout errors
144    Timeout,
145    /// Server errors (5xx)
146    Server,
147    /// Not found errors
148    NotFound,
149    /// Validation errors
150    Validation,
151    /// Parsing errors
152    Parsing,
153    /// Other errors
154    Other,
155}
156
157/// Result type alias for library operations
158pub type Result<T> = std::result::Result<T, FinanceError>;
159
160impl FinanceError {
161    /// Check if this error is retriable
162    pub fn is_retriable(&self) -> bool {
163        matches!(
164            self,
165            FinanceError::Timeout { .. }
166                | FinanceError::RateLimited { .. }
167                | FinanceError::HttpError(_)
168                | FinanceError::AuthenticationFailed { .. }
169                | FinanceError::ServerError { .. }
170        )
171    }
172
173    /// Check if this error indicates an authentication issue
174    pub fn is_auth_error(&self) -> bool {
175        matches!(self, FinanceError::AuthenticationFailed { .. })
176    }
177
178    /// Check if this error indicates a not found issue
179    pub fn is_not_found(&self) -> bool {
180        matches!(self, FinanceError::SymbolNotFound { .. })
181    }
182
183    /// Get retry delay in seconds (for exponential backoff)
184    pub fn retry_after_secs(&self) -> Option<u64> {
185        match self {
186            Self::RateLimited { retry_after } => *retry_after,
187            Self::Timeout { .. } => Some(2),
188            Self::ServerError { status, .. } if *status >= 500 => Some(5),
189            Self::AuthenticationFailed { .. } => Some(1),
190            _ => None,
191        }
192    }
193
194    /// Categorize errors for logging/metrics
195    pub fn category(&self) -> ErrorCategory {
196        match self {
197            Self::AuthenticationFailed { .. } => ErrorCategory::Auth,
198            Self::RateLimited { .. } => ErrorCategory::RateLimit,
199            Self::Timeout { .. } => ErrorCategory::Timeout,
200            Self::ServerError { .. } => ErrorCategory::Server,
201            Self::SymbolNotFound { .. } => ErrorCategory::NotFound,
202            Self::InvalidParameter { .. } => ErrorCategory::Validation,
203            Self::JsonParseError(_) | Self::ResponseStructureError { .. } => ErrorCategory::Parsing,
204            Self::NotSupported { .. } | Self::NoProviderAvailable { .. } => {
205                ErrorCategory::Validation
206            }
207            _ => ErrorCategory::Other,
208        }
209    }
210
211    /// Add symbol context to error (fluent API)
212    pub fn with_symbol(mut self, symbol: impl Into<String>) -> Self {
213        if let Self::SymbolNotFound {
214            symbol: ref mut s, ..
215        } = self
216        {
217            *s = Some(symbol.into());
218        }
219        self
220    }
221
222    /// Add context to error (fluent API)
223    pub fn with_context(mut self, context: impl Into<String>) -> Self {
224        match self {
225            Self::AuthenticationFailed {
226                context: ref mut c, ..
227            } => {
228                *c = context.into();
229            }
230            Self::SymbolNotFound {
231                context: ref mut c, ..
232            } => {
233                *c = context.into();
234            }
235            Self::ResponseStructureError {
236                context: ref mut c, ..
237            } => {
238                *c = context.into();
239            }
240            Self::ServerError {
241                context: ref mut c, ..
242            } => {
243                *c = context.into();
244            }
245            _ => {}
246        }
247        self
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn test_error_is_retriable() {
257        assert!(FinanceError::Timeout { timeout_ms: 5000 }.is_retriable());
258        assert!(FinanceError::RateLimited { retry_after: None }.is_retriable());
259        assert!(
260            FinanceError::AuthenticationFailed {
261                context: "test".to_string()
262            }
263            .is_retriable()
264        );
265        assert!(
266            FinanceError::ServerError {
267                status: 500,
268                context: "test".to_string()
269            }
270            .is_retriable()
271        );
272        assert!(
273            !FinanceError::SymbolNotFound {
274                symbol: Some("AAPL".to_string()),
275                context: "test".to_string()
276            }
277            .is_retriable()
278        );
279        assert!(
280            !FinanceError::InvalidParameter {
281                param: "test".to_string(),
282                reason: "invalid".to_string()
283            }
284            .is_retriable()
285        );
286    }
287
288    #[test]
289    fn test_error_is_auth_error() {
290        assert!(
291            FinanceError::AuthenticationFailed {
292                context: "test".to_string()
293            }
294            .is_auth_error()
295        );
296        assert!(!FinanceError::Timeout { timeout_ms: 5000 }.is_auth_error());
297    }
298
299    #[test]
300    fn test_error_is_not_found() {
301        assert!(
302            FinanceError::SymbolNotFound {
303                symbol: Some("AAPL".to_string()),
304                context: "test".to_string()
305            }
306            .is_not_found()
307        );
308        assert!(!FinanceError::Timeout { timeout_ms: 5000 }.is_not_found());
309    }
310
311    #[test]
312    fn test_retry_after_secs() {
313        assert_eq!(
314            FinanceError::RateLimited {
315                retry_after: Some(10)
316            }
317            .retry_after_secs(),
318            Some(10)
319        );
320        assert_eq!(
321            FinanceError::Timeout { timeout_ms: 5000 }.retry_after_secs(),
322            Some(2)
323        );
324        assert_eq!(
325            FinanceError::ServerError {
326                status: 503,
327                context: "test".to_string()
328            }
329            .retry_after_secs(),
330            Some(5)
331        );
332        assert_eq!(
333            FinanceError::SymbolNotFound {
334                symbol: None,
335                context: "test".to_string()
336            }
337            .retry_after_secs(),
338            None
339        );
340    }
341
342    #[test]
343    fn test_error_category() {
344        assert_eq!(
345            FinanceError::AuthenticationFailed {
346                context: "test".to_string()
347            }
348            .category(),
349            ErrorCategory::Auth
350        );
351        assert_eq!(
352            FinanceError::RateLimited { retry_after: None }.category(),
353            ErrorCategory::RateLimit
354        );
355        assert_eq!(
356            FinanceError::Timeout { timeout_ms: 5000 }.category(),
357            ErrorCategory::Timeout
358        );
359        assert_eq!(
360            FinanceError::SymbolNotFound {
361                symbol: None,
362                context: "test".to_string()
363            }
364            .category(),
365            ErrorCategory::NotFound
366        );
367    }
368
369    #[test]
370    fn test_with_symbol() {
371        let error = FinanceError::SymbolNotFound {
372            symbol: None,
373            context: "test".to_string(),
374        }
375        .with_symbol("AAPL");
376
377        if let FinanceError::SymbolNotFound { symbol, .. } = error {
378            assert_eq!(symbol, Some("AAPL".to_string()));
379        } else {
380            panic!("Expected SymbolNotFound");
381        }
382    }
383
384    #[test]
385    fn test_with_context() {
386        let error = FinanceError::AuthenticationFailed {
387            context: "old".to_string(),
388        }
389        .with_context("new context");
390
391        if let FinanceError::AuthenticationFailed { context } = error {
392            assert_eq!(context, "new context");
393        } else {
394            panic!("Expected AuthenticationFailed");
395        }
396    }
397}