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#[derive(Error, Debug)]
14pub enum FinanceError {
15 #[error("Authentication failed: {context}")]
17 AuthenticationFailed {
18 context: String,
20 },
21
22 #[error("Symbol not found: {}", symbol.as_deref().unwrap_or("unknown"))]
24 SymbolNotFound {
25 symbol: Option<String>,
27 context: String,
29 },
30
31 #[error("Rate limited (retry after {retry_after:?}s)")]
33 RateLimited {
34 retry_after: Option<u64>,
36 },
37
38 #[error("HTTP request failed: {0}")]
40 HttpError(#[from] reqwest::Error),
41
42 #[error("JSON parse error: {0}")]
44 JsonParseError(#[from] serde_json::Error),
45
46 #[error("Response structure error in '{field}': {context}")]
48 ResponseStructureError {
49 field: String,
51 context: String,
53 },
54
55 #[error("Invalid parameter '{param}': {reason}")]
57 InvalidParameter {
58 param: String,
60 reason: String,
62 },
63
64 #[error("Request timeout after {timeout_ms}ms")]
66 Timeout {
67 timeout_ms: u64,
69 },
70
71 #[error("Server error {status}: {context}")]
73 ServerError {
74 status: u16,
76 context: String,
78 },
79
80 #[error("Unexpected response: {0}")]
82 UnexpectedResponse(String),
83
84 #[error("Internal error: {0}")]
86 InternalError(String),
87
88 #[error("API error: {0}")]
90 ApiError(String),
91
92 #[error("Runtime error: {0}")]
94 RuntimeError(#[from] std::io::Error),
95
96 #[cfg(feature = "indicators")]
98 #[error("Indicator calculation error: {0}")]
99 IndicatorError(#[from] crate::indicators::IndicatorError),
100
101 #[error("External API error from '{api}': HTTP {status}")]
103 ExternalApiError {
104 api: String,
106 status: u16,
108 },
109
110 #[error("Macro data error from '{provider}': {context}")]
112 MacroDataError {
113 provider: String,
115 context: String,
117 },
118
119 #[error("Feed parse error for '{url}': {context}")]
121 FeedParseError {
122 url: String,
124 context: String,
126 },
127
128 #[error(
130 "{provider} does not support {operation} (supported by: {}; route it via Providers::builder().route(...))",
131 join_providers(candidates)
132 )]
133 NotSupported {
134 provider: Provider,
136 operation: Operation,
138 candidates: Vec<Provider>,
141 },
142
143 #[error(
145 "no provider available for {operation} (supported by: {}; route it via Providers::builder().route(...))",
146 join_providers(candidates)
147 )]
148 NoProviderAvailable {
149 operation: Capability,
151 candidates: Vec<Provider>,
154 },
155
156 #[cfg(feature = "translation")]
158 #[error("Translation error: {context}")]
159 TranslationError {
160 context: String,
162 },
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum ErrorCategory {
168 Auth,
170 RateLimit,
172 Timeout,
174 Server,
176 NotFound,
178 Validation,
180 Parsing,
182 Other,
184}
185
186pub type Result<T> = std::result::Result<T, FinanceError>;
188
189impl FinanceError {
190 pub fn is_retriable(&self) -> bool {
192 matches!(
193 self,
194 FinanceError::Timeout { .. }
195 | FinanceError::RateLimited { .. }
196 | FinanceError::HttpError(_)
197 | FinanceError::AuthenticationFailed { .. }
198 | FinanceError::ServerError { .. }
199 ) || matches!(self, FinanceError::ExternalApiError { status, .. } if *status >= 500)
200 }
201
202 pub fn is_auth_error(&self) -> bool {
204 matches!(self, FinanceError::AuthenticationFailed { .. })
205 }
206
207 pub fn is_not_found(&self) -> bool {
209 matches!(self, FinanceError::SymbolNotFound { .. })
210 }
211
212 pub fn retry_after_secs(&self) -> Option<u64> {
214 match self {
215 Self::RateLimited { retry_after } => *retry_after,
216 Self::Timeout { .. } => Some(2),
217 Self::ServerError { status, .. } if *status >= 500 => Some(5),
218 Self::AuthenticationFailed { .. } => Some(1),
219 _ => None,
220 }
221 }
222
223 pub fn category(&self) -> ErrorCategory {
225 match self {
226 Self::AuthenticationFailed { .. } => ErrorCategory::Auth,
227 Self::RateLimited { .. } => ErrorCategory::RateLimit,
228 Self::Timeout { .. } => ErrorCategory::Timeout,
229 Self::ServerError { .. } => ErrorCategory::Server,
230 Self::SymbolNotFound { .. } => ErrorCategory::NotFound,
231 Self::InvalidParameter { .. } => ErrorCategory::Validation,
232 Self::JsonParseError(_)
233 | Self::ResponseStructureError { .. }
234 | Self::MacroDataError { .. }
235 | Self::FeedParseError { .. } => ErrorCategory::Parsing,
236 Self::NotSupported { .. } | Self::NoProviderAvailable { .. } => {
237 ErrorCategory::Validation
238 }
239 Self::ExternalApiError { .. } => ErrorCategory::Server,
240 _ => ErrorCategory::Other,
241 }
242 }
243
244 pub fn with_symbol(mut self, symbol: impl Into<String>) -> Self {
246 if let Self::SymbolNotFound {
247 symbol: ref mut s, ..
248 } = self
249 {
250 *s = Some(symbol.into());
251 }
252 self
253 }
254
255 pub fn with_context(mut self, context: impl Into<String>) -> Self {
257 match self {
258 Self::AuthenticationFailed {
259 context: ref mut c, ..
260 } => {
261 *c = context.into();
262 }
263 Self::SymbolNotFound {
264 context: ref mut c, ..
265 } => {
266 *c = context.into();
267 }
268 Self::ResponseStructureError {
269 context: ref mut c, ..
270 } => {
271 *c = context.into();
272 }
273 Self::ServerError {
274 context: ref mut c, ..
275 } => {
276 *c = context.into();
277 }
278 Self::MacroDataError {
279 context: ref mut c, ..
280 } => {
281 *c = context.into();
282 }
283 Self::FeedParseError {
284 context: ref mut c, ..
285 } => {
286 *c = context.into();
287 }
288 _ => {}
289 }
290 self
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn test_error_is_retriable() {
300 assert!(FinanceError::Timeout { timeout_ms: 5000 }.is_retriable());
301 assert!(FinanceError::RateLimited { retry_after: None }.is_retriable());
302 assert!(
303 FinanceError::AuthenticationFailed {
304 context: "test".to_string()
305 }
306 .is_retriable()
307 );
308 assert!(
309 FinanceError::ServerError {
310 status: 500,
311 context: "test".to_string()
312 }
313 .is_retriable()
314 );
315 assert!(
316 !FinanceError::SymbolNotFound {
317 symbol: Some("AAPL".to_string()),
318 context: "test".to_string()
319 }
320 .is_retriable()
321 );
322 assert!(
323 !FinanceError::InvalidParameter {
324 param: "test".to_string(),
325 reason: "invalid".to_string()
326 }
327 .is_retriable()
328 );
329 }
330
331 #[test]
332 fn test_error_is_auth_error() {
333 assert!(
334 FinanceError::AuthenticationFailed {
335 context: "test".to_string()
336 }
337 .is_auth_error()
338 );
339 assert!(!FinanceError::Timeout { timeout_ms: 5000 }.is_auth_error());
340 }
341
342 #[test]
343 fn test_error_is_not_found() {
344 assert!(
345 FinanceError::SymbolNotFound {
346 symbol: Some("AAPL".to_string()),
347 context: "test".to_string()
348 }
349 .is_not_found()
350 );
351 assert!(!FinanceError::Timeout { timeout_ms: 5000 }.is_not_found());
352 }
353
354 #[test]
355 fn test_retry_after_secs() {
356 assert_eq!(
357 FinanceError::RateLimited {
358 retry_after: Some(10)
359 }
360 .retry_after_secs(),
361 Some(10)
362 );
363 assert_eq!(
364 FinanceError::Timeout { timeout_ms: 5000 }.retry_after_secs(),
365 Some(2)
366 );
367 assert_eq!(
368 FinanceError::ServerError {
369 status: 503,
370 context: "test".to_string()
371 }
372 .retry_after_secs(),
373 Some(5)
374 );
375 assert_eq!(
376 FinanceError::SymbolNotFound {
377 symbol: None,
378 context: "test".to_string()
379 }
380 .retry_after_secs(),
381 None
382 );
383 }
384
385 #[test]
386 fn test_error_category() {
387 assert_eq!(
388 FinanceError::AuthenticationFailed {
389 context: "test".to_string()
390 }
391 .category(),
392 ErrorCategory::Auth
393 );
394 assert_eq!(
395 FinanceError::RateLimited { retry_after: None }.category(),
396 ErrorCategory::RateLimit
397 );
398 assert_eq!(
399 FinanceError::Timeout { timeout_ms: 5000 }.category(),
400 ErrorCategory::Timeout
401 );
402 assert_eq!(
403 FinanceError::SymbolNotFound {
404 symbol: None,
405 context: "test".to_string()
406 }
407 .category(),
408 ErrorCategory::NotFound
409 );
410 }
411
412 #[test]
413 fn test_with_symbol() {
414 let error = FinanceError::SymbolNotFound {
415 symbol: None,
416 context: "test".to_string(),
417 }
418 .with_symbol("AAPL");
419
420 if let FinanceError::SymbolNotFound { symbol, .. } = error {
421 assert_eq!(symbol, Some("AAPL".to_string()));
422 } else {
423 panic!("Expected SymbolNotFound");
424 }
425 }
426
427 #[test]
428 fn test_with_context() {
429 let error = FinanceError::AuthenticationFailed {
430 context: "old".to_string(),
431 }
432 .with_context("new context");
433
434 if let FinanceError::AuthenticationFailed { context } = error {
435 assert_eq!(context, "new context");
436 } else {
437 panic!("Expected AuthenticationFailed");
438 }
439 }
440}