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)]
14#[non_exhaustive]
15pub enum FinanceError {
16 #[error("Authentication failed: {context}")]
18 AuthenticationFailed {
19 context: String,
21 },
22
23 #[error("Not entitled: {context}")]
29 NotEntitled {
30 context: String,
32 },
33
34 #[error("{provider} is not configured; set {env_var}")]
39 ProviderNotConfigured {
40 provider: String,
42 env_var: String,
44 },
45
46 #[error("Symbol not found: {}", symbol.as_deref().unwrap_or("unknown"))]
48 SymbolNotFound {
49 symbol: Option<String>,
51 context: String,
53 },
54
55 #[error("Rate limited{}", match retry_after {
57 Some(s) => format!(" (retry after {s}s)"),
58 None => String::new(),
59 })]
60 RateLimited {
61 retry_after: Option<u64>,
63 },
64
65 #[error("HTTP request failed: {0}")]
67 HttpError(#[from] reqwest::Error),
68
69 #[error("Network request to {api} failed")]
75 NetworkError {
76 api: String,
78 },
79
80 #[error("JSON parse error: {0}")]
82 JsonParseError(#[from] serde_json::Error),
83
84 #[error("Response structure error in '{field}': {context}")]
86 ResponseStructureError {
87 field: String,
89 context: String,
91 },
92
93 #[error("Invalid parameter '{param}': {reason}")]
95 InvalidParameter {
96 param: String,
98 reason: String,
100 },
101
102 #[error("Request timeout after {timeout_ms}ms")]
104 Timeout {
105 timeout_ms: u64,
107 },
108
109 #[error("Server error {status}: {context}")]
111 ServerError {
112 status: u16,
114 context: String,
116 },
117
118 #[error("Unexpected response: {0}")]
120 UnexpectedResponse(String),
121
122 #[error("Internal error: {0}")]
124 InternalError(String),
125
126 #[error("API error: {0}")]
128 ApiError(String),
129
130 #[error("Runtime error: {0}")]
132 RuntimeError(#[from] std::io::Error),
133
134 #[cfg(feature = "indicators")]
136 #[error("Indicator calculation error: {0}")]
137 IndicatorError(#[from] crate::indicators::IndicatorError),
138
139 #[error("External API error from '{api}': HTTP {status}")]
141 ExternalApiError {
142 api: String,
144 status: u16,
146 },
147
148 #[error("Macro data error from '{provider}': {context}")]
150 MacroDataError {
151 provider: String,
153 context: String,
155 },
156
157 #[error("Feed parse error for '{url}': {context}")]
159 FeedParseError {
160 url: String,
162 context: String,
164 },
165
166 #[error(
168 "{provider} does not support {operation} (supported by: {}; route it via Providers::builder().route(...))",
169 join_providers(candidates)
170 )]
171 NotSupported {
172 provider: Provider,
174 operation: Operation,
176 candidates: Vec<Provider>,
179 },
180
181 #[error(
183 "no provider available for {operation} (supported by: {}; route it via Providers::builder().route(...))",
184 join_providers(candidates)
185 )]
186 NoProviderAvailable {
187 operation: Capability,
189 candidates: Vec<Provider>,
192 },
193
194 #[error(
196 "no adapter registered for {provider}; register it with \
197 ProvidersBuilder::with_adapter(..) before routing to it"
198 )]
199 ProviderNotRegistered {
200 provider: Provider,
202 },
203
204 #[cfg(feature = "translation")]
206 #[error("Translation error: {context}")]
207 TranslationError {
208 context: String,
210 },
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub enum ErrorCategory {
216 Auth,
218 RateLimit,
220 Timeout,
222 Server,
224 NotFound,
226 Validation,
228 Parsing,
230 Other,
232}
233
234pub type Result<T> = std::result::Result<T, FinanceError>;
236
237impl FinanceError {
238 pub fn is_retriable(&self) -> bool {
240 matches!(
241 self,
242 FinanceError::Timeout { .. }
243 | FinanceError::RateLimited { .. }
244 | FinanceError::HttpError(_)
245 | FinanceError::NetworkError { .. }
246 | FinanceError::AuthenticationFailed { .. }
247 | FinanceError::ServerError { .. }
248 ) || matches!(self, FinanceError::ExternalApiError { status, .. } if *status >= 500)
249 }
250
251 pub fn is_auth_error(&self) -> bool {
253 matches!(self, FinanceError::AuthenticationFailed { .. })
254 }
255
256 pub fn is_not_found(&self) -> bool {
258 matches!(self, FinanceError::SymbolNotFound { .. })
259 }
260
261 pub fn retry_after_secs(&self) -> Option<u64> {
263 match self {
264 Self::RateLimited { retry_after } => *retry_after,
265 Self::Timeout { .. } => Some(2),
266 Self::ServerError { status, .. } if *status >= 500 => Some(5),
267 Self::AuthenticationFailed { .. } => Some(1),
268 _ => None,
269 }
270 }
271
272 pub fn category(&self) -> ErrorCategory {
274 match self {
275 Self::AuthenticationFailed { .. } | Self::NotEntitled { .. } => ErrorCategory::Auth,
276 Self::RateLimited { .. } => ErrorCategory::RateLimit,
277 Self::Timeout { .. } => ErrorCategory::Timeout,
278 Self::ServerError { .. } => ErrorCategory::Server,
279 Self::SymbolNotFound { .. } => ErrorCategory::NotFound,
280 Self::InvalidParameter { .. } => ErrorCategory::Validation,
281 Self::JsonParseError(_)
282 | Self::ResponseStructureError { .. }
283 | Self::MacroDataError { .. }
284 | Self::FeedParseError { .. } => ErrorCategory::Parsing,
285 Self::NotSupported { .. } | Self::NoProviderAvailable { .. } => {
286 ErrorCategory::Validation
287 }
288 Self::ExternalApiError { .. } => ErrorCategory::Server,
289 _ => ErrorCategory::Other,
290 }
291 }
292
293 pub fn with_symbol(mut self, symbol: impl Into<String>) -> Self {
295 if let Self::SymbolNotFound {
296 symbol: ref mut s, ..
297 } = self
298 {
299 *s = Some(symbol.into());
300 }
301 self
302 }
303
304 pub fn with_context(mut self, context: impl Into<String>) -> Self {
306 match self {
307 Self::AuthenticationFailed {
308 context: ref mut c, ..
309 } => {
310 *c = context.into();
311 }
312 Self::SymbolNotFound {
313 context: ref mut c, ..
314 } => {
315 *c = context.into();
316 }
317 Self::ResponseStructureError {
318 context: ref mut c, ..
319 } => {
320 *c = context.into();
321 }
322 Self::ServerError {
323 context: ref mut c, ..
324 } => {
325 *c = context.into();
326 }
327 Self::MacroDataError {
328 context: ref mut c, ..
329 } => {
330 *c = context.into();
331 }
332 Self::FeedParseError {
333 context: ref mut c, ..
334 } => {
335 *c = context.into();
336 }
337 _ => {}
338 }
339 self
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn test_error_is_retriable() {
349 assert!(FinanceError::Timeout { timeout_ms: 5000 }.is_retriable());
350 assert!(FinanceError::RateLimited { retry_after: None }.is_retriable());
351 assert!(
352 FinanceError::AuthenticationFailed {
353 context: "test".to_string()
354 }
355 .is_retriable()
356 );
357 assert!(
358 FinanceError::ServerError {
359 status: 500,
360 context: "test".to_string()
361 }
362 .is_retriable()
363 );
364 assert!(
365 !FinanceError::SymbolNotFound {
366 symbol: Some("AAPL".to_string()),
367 context: "test".to_string()
368 }
369 .is_retriable()
370 );
371 assert!(
372 !FinanceError::InvalidParameter {
373 param: "test".to_string(),
374 reason: "invalid".to_string()
375 }
376 .is_retriable()
377 );
378 }
379
380 #[test]
381 fn test_error_is_auth_error() {
382 assert!(
383 FinanceError::AuthenticationFailed {
384 context: "test".to_string()
385 }
386 .is_auth_error()
387 );
388 assert!(!FinanceError::Timeout { timeout_ms: 5000 }.is_auth_error());
389 }
390
391 #[test]
392 fn test_error_is_not_found() {
393 assert!(
394 FinanceError::SymbolNotFound {
395 symbol: Some("AAPL".to_string()),
396 context: "test".to_string()
397 }
398 .is_not_found()
399 );
400 assert!(!FinanceError::Timeout { timeout_ms: 5000 }.is_not_found());
401 }
402
403 #[test]
404 fn test_retry_after_secs() {
405 assert_eq!(
406 FinanceError::RateLimited {
407 retry_after: Some(10)
408 }
409 .retry_after_secs(),
410 Some(10)
411 );
412 assert_eq!(
413 FinanceError::Timeout { timeout_ms: 5000 }.retry_after_secs(),
414 Some(2)
415 );
416 assert_eq!(
417 FinanceError::ServerError {
418 status: 503,
419 context: "test".to_string()
420 }
421 .retry_after_secs(),
422 Some(5)
423 );
424 assert_eq!(
425 FinanceError::SymbolNotFound {
426 symbol: None,
427 context: "test".to_string()
428 }
429 .retry_after_secs(),
430 None
431 );
432 }
433
434 #[test]
435 fn test_error_category() {
436 assert_eq!(
437 FinanceError::AuthenticationFailed {
438 context: "test".to_string()
439 }
440 .category(),
441 ErrorCategory::Auth
442 );
443 assert_eq!(
444 FinanceError::RateLimited { retry_after: None }.category(),
445 ErrorCategory::RateLimit
446 );
447 assert_eq!(
448 FinanceError::Timeout { timeout_ms: 5000 }.category(),
449 ErrorCategory::Timeout
450 );
451 assert_eq!(
452 FinanceError::SymbolNotFound {
453 symbol: None,
454 context: "test".to_string()
455 }
456 .category(),
457 ErrorCategory::NotFound
458 );
459 }
460
461 #[test]
462 fn test_with_symbol() {
463 let error = FinanceError::SymbolNotFound {
464 symbol: None,
465 context: "test".to_string(),
466 }
467 .with_symbol("AAPL");
468
469 if let FinanceError::SymbolNotFound { symbol, .. } = error {
470 assert_eq!(symbol, Some("AAPL".to_string()));
471 } else {
472 panic!("Expected SymbolNotFound");
473 }
474 }
475
476 #[test]
477 fn test_with_context() {
478 let error = FinanceError::AuthenticationFailed {
479 context: "old".to_string(),
480 }
481 .with_context("new context");
482
483 if let FinanceError::AuthenticationFailed { context } = error {
484 assert_eq!(context, "new context");
485 } else {
486 panic!("Expected AuthenticationFailed");
487 }
488 }
489}