1use thiserror::Error;
2
3#[derive(Error, Debug)]
5pub enum FinanceError {
6 #[error("Authentication failed: {context}")]
8 AuthenticationFailed {
9 context: String,
11 },
12
13 #[error("Symbol not found: {}", symbol.as_ref().map(|s| s.as_str()).unwrap_or("unknown"))]
15 SymbolNotFound {
16 symbol: Option<String>,
18 context: String,
20 },
21
22 #[error("Rate limited (retry after {retry_after:?}s)")]
24 RateLimited {
25 retry_after: Option<u64>,
27 },
28
29 #[error("HTTP request failed: {0}")]
31 HttpError(#[from] reqwest::Error),
32
33 #[error("JSON parse error: {0}")]
35 JsonParseError(#[from] serde_json::Error),
36
37 #[error("Response structure error in '{field}': {context}")]
39 ResponseStructureError {
40 field: String,
42 context: String,
44 },
45
46 #[error("Invalid parameter '{param}': {reason}")]
48 InvalidParameter {
49 param: String,
51 reason: String,
53 },
54
55 #[error("Request timeout after {timeout_ms}ms")]
57 Timeout {
58 timeout_ms: u64,
60 },
61
62 #[error("Server error {status}: {context}")]
64 ServerError {
65 status: u16,
67 context: String,
69 },
70
71 #[error("Unexpected response: {0}")]
73 UnexpectedResponse(String),
74
75 #[error("Internal error: {0}")]
77 InternalError(String),
78
79 #[error("API error: {0}")]
81 ApiError(String),
82
83 #[error("Runtime error: {0}")]
85 RuntimeError(#[from] std::io::Error),
86
87 #[cfg(feature = "indicators")]
89 #[error("Indicator calculation error: {0}")]
90 IndicatorError(#[from] crate::indicators::IndicatorError),
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum ErrorCategory {
96 Auth,
98 RateLimit,
100 Timeout,
102 Server,
104 NotFound,
106 Validation,
108 Parsing,
110 Other,
112}
113
114pub type Error = FinanceError;
116
117pub type Result<T> = std::result::Result<T, FinanceError>;
119
120impl FinanceError {
121 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 pub fn is_auth_error(&self) -> bool {
135 matches!(self, FinanceError::AuthenticationFailed { .. })
136 }
137
138 pub fn is_not_found(&self) -> bool {
140 matches!(self, FinanceError::SymbolNotFound { .. })
141 }
142
143 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 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 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 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
208impl FinanceError {
210 #[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}