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_ref().map(|s| s.as_str()).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
93/// Error category for logging and metrics
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum ErrorCategory {
96    /// Authentication errors
97    Auth,
98    /// Rate limiting errors
99    RateLimit,
100    /// Timeout errors
101    Timeout,
102    /// Server errors (5xx)
103    Server,
104    /// Not found errors
105    NotFound,
106    /// Validation errors
107    Validation,
108    /// Parsing errors
109    Parsing,
110    /// Other errors
111    Other,
112}
113
114/// Type alias for Error (for consistency with common Rust patterns)
115pub type Error = FinanceError;
116
117/// Result type alias for library operations
118pub type Result<T> = std::result::Result<T, FinanceError>;
119
120impl FinanceError {
121    /// Check if this error is retriable
122    pub fn is_retriable(&self) -> bool {
123        matches!(
124            self,
125            FinanceError::Timeout { .. }
126                | FinanceError::RateLimited { .. }
127                | FinanceError::HttpError(_)
128                | FinanceError::AuthenticationFailed { .. }
129                | FinanceError::ServerError { .. }
130        )
131    }
132
133    /// Check if this error indicates an authentication issue
134    pub fn is_auth_error(&self) -> bool {
135        matches!(self, FinanceError::AuthenticationFailed { .. })
136    }
137
138    /// Check if this error indicates a not found issue
139    pub fn is_not_found(&self) -> bool {
140        matches!(self, FinanceError::SymbolNotFound { .. })
141    }
142
143    /// Get retry delay in seconds (for exponential backoff)
144    pub fn retry_after_secs(&self) -> Option<u64> {
145        match self {
146            Self::RateLimited { retry_after } => *retry_after,
147            Self::Timeout { .. } => Some(2),
148            Self::ServerError { status, .. } if *status >= 500 => Some(5),
149            Self::AuthenticationFailed { .. } => Some(1),
150            _ => None,
151        }
152    }
153
154    /// Categorize errors for logging/metrics
155    pub fn category(&self) -> ErrorCategory {
156        match self {
157            Self::AuthenticationFailed { .. } => ErrorCategory::Auth,
158            Self::RateLimited { .. } => ErrorCategory::RateLimit,
159            Self::Timeout { .. } => ErrorCategory::Timeout,
160            Self::ServerError { .. } => ErrorCategory::Server,
161            Self::SymbolNotFound { .. } => ErrorCategory::NotFound,
162            Self::InvalidParameter { .. } => ErrorCategory::Validation,
163            Self::JsonParseError(_) | Self::ResponseStructureError { .. } => ErrorCategory::Parsing,
164            _ => ErrorCategory::Other,
165        }
166    }
167
168    /// Add symbol context to error (fluent API)
169    pub fn with_symbol(mut self, symbol: impl Into<String>) -> Self {
170        if let Self::SymbolNotFound {
171            symbol: ref mut s, ..
172        } = self
173        {
174            *s = Some(symbol.into());
175        }
176        self
177    }
178
179    /// Add context to error (fluent API)
180    pub fn with_context(mut self, context: impl Into<String>) -> Self {
181        match self {
182            Self::AuthenticationFailed {
183                context: ref mut c, ..
184            } => {
185                *c = context.into();
186            }
187            Self::SymbolNotFound {
188                context: ref mut c, ..
189            } => {
190                *c = context.into();
191            }
192            Self::ResponseStructureError {
193                context: ref mut c, ..
194            } => {
195                *c = context.into();
196            }
197            Self::ServerError {
198                context: ref mut c, ..
199            } => {
200                *c = context.into();
201            }
202            _ => {}
203        }
204        self
205    }
206}
207
208// Backward compatibility: Allow ParseError to be created from String
209impl FinanceError {
210    /// Create a ParseError from a string (for backward compatibility)
211    #[deprecated(since = "2.0.0", note = "Use ResponseStructureError instead")]
212    pub fn parse_error(msg: impl Into<String>) -> Self {
213        let msg = msg.into();
214        Self::ResponseStructureError {
215            field: "unknown".to_string(),
216            context: msg,
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_error_is_retriable() {
227        assert!(FinanceError::Timeout { timeout_ms: 5000 }.is_retriable());
228        assert!(FinanceError::RateLimited { retry_after: None }.is_retriable());
229        assert!(
230            FinanceError::AuthenticationFailed {
231                context: "test".to_string()
232            }
233            .is_retriable()
234        );
235        assert!(
236            FinanceError::ServerError {
237                status: 500,
238                context: "test".to_string()
239            }
240            .is_retriable()
241        );
242        assert!(
243            !FinanceError::SymbolNotFound {
244                symbol: Some("AAPL".to_string()),
245                context: "test".to_string()
246            }
247            .is_retriable()
248        );
249        assert!(
250            !FinanceError::InvalidParameter {
251                param: "test".to_string(),
252                reason: "invalid".to_string()
253            }
254            .is_retriable()
255        );
256    }
257
258    #[test]
259    fn test_error_is_auth_error() {
260        assert!(
261            FinanceError::AuthenticationFailed {
262                context: "test".to_string()
263            }
264            .is_auth_error()
265        );
266        assert!(!FinanceError::Timeout { timeout_ms: 5000 }.is_auth_error());
267    }
268
269    #[test]
270    fn test_error_is_not_found() {
271        assert!(
272            FinanceError::SymbolNotFound {
273                symbol: Some("AAPL".to_string()),
274                context: "test".to_string()
275            }
276            .is_not_found()
277        );
278        assert!(!FinanceError::Timeout { timeout_ms: 5000 }.is_not_found());
279    }
280
281    #[test]
282    fn test_retry_after_secs() {
283        assert_eq!(
284            FinanceError::RateLimited {
285                retry_after: Some(10)
286            }
287            .retry_after_secs(),
288            Some(10)
289        );
290        assert_eq!(
291            FinanceError::Timeout { timeout_ms: 5000 }.retry_after_secs(),
292            Some(2)
293        );
294        assert_eq!(
295            FinanceError::ServerError {
296                status: 503,
297                context: "test".to_string()
298            }
299            .retry_after_secs(),
300            Some(5)
301        );
302        assert_eq!(
303            FinanceError::SymbolNotFound {
304                symbol: None,
305                context: "test".to_string()
306            }
307            .retry_after_secs(),
308            None
309        );
310    }
311
312    #[test]
313    fn test_error_category() {
314        assert_eq!(
315            FinanceError::AuthenticationFailed {
316                context: "test".to_string()
317            }
318            .category(),
319            ErrorCategory::Auth
320        );
321        assert_eq!(
322            FinanceError::RateLimited { retry_after: None }.category(),
323            ErrorCategory::RateLimit
324        );
325        assert_eq!(
326            FinanceError::Timeout { timeout_ms: 5000 }.category(),
327            ErrorCategory::Timeout
328        );
329        assert_eq!(
330            FinanceError::SymbolNotFound {
331                symbol: None,
332                context: "test".to_string()
333            }
334            .category(),
335            ErrorCategory::NotFound
336        );
337    }
338
339    #[test]
340    fn test_with_symbol() {
341        let error = FinanceError::SymbolNotFound {
342            symbol: None,
343            context: "test".to_string(),
344        }
345        .with_symbol("AAPL");
346
347        if let FinanceError::SymbolNotFound { symbol, .. } = error {
348            assert_eq!(symbol, Some("AAPL".to_string()));
349        } else {
350            panic!("Expected SymbolNotFound");
351        }
352    }
353
354    #[test]
355    fn test_with_context() {
356        let error = FinanceError::AuthenticationFailed {
357            context: "old".to_string(),
358        }
359        .with_context("new context");
360
361        if let FinanceError::AuthenticationFailed { context } = error {
362            assert_eq!(context, "new context");
363        } else {
364            panic!("Expected AuthenticationFailed");
365        }
366    }
367}