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("{provider} is not configured; set {env_var}")]
28 ProviderNotConfigured {
29 provider: String,
31 env_var: String,
33 },
34
35 #[error("Symbol not found: {}", symbol.as_deref().unwrap_or("unknown"))]
37 SymbolNotFound {
38 symbol: Option<String>,
40 context: String,
42 },
43
44 #[error("Rate limited{}", match retry_after {
46 Some(s) => format!(" (retry after {s}s)"),
47 None => String::new(),
48 })]
49 RateLimited {
50 retry_after: Option<u64>,
52 },
53
54 #[error("HTTP request failed: {0}")]
56 HttpError(#[from] reqwest::Error),
57
58 #[error("Network request to {api} failed")]
64 NetworkError {
65 api: String,
67 },
68
69 #[error("JSON parse error: {0}")]
71 JsonParseError(#[from] serde_json::Error),
72
73 #[error("Response structure error in '{field}': {context}")]
75 ResponseStructureError {
76 field: String,
78 context: String,
80 },
81
82 #[error("Invalid parameter '{param}': {reason}")]
84 InvalidParameter {
85 param: String,
87 reason: String,
89 },
90
91 #[error("Request timeout after {timeout_ms}ms")]
93 Timeout {
94 timeout_ms: u64,
96 },
97
98 #[error("Server error {status}: {context}")]
100 ServerError {
101 status: u16,
103 context: String,
105 },
106
107 #[error("Unexpected response: {0}")]
109 UnexpectedResponse(String),
110
111 #[error("Internal error: {0}")]
113 InternalError(String),
114
115 #[error("API error: {0}")]
117 ApiError(String),
118
119 #[error("Runtime error: {0}")]
121 RuntimeError(#[from] std::io::Error),
122
123 #[cfg(feature = "indicators")]
125 #[error("Indicator calculation error: {0}")]
126 IndicatorError(#[from] crate::indicators::IndicatorError),
127
128 #[error("External API error from '{api}': HTTP {status}")]
130 ExternalApiError {
131 api: String,
133 status: u16,
135 },
136
137 #[error("Macro data error from '{provider}': {context}")]
139 MacroDataError {
140 provider: String,
142 context: String,
144 },
145
146 #[error("Feed parse error for '{url}': {context}")]
148 FeedParseError {
149 url: String,
151 context: String,
153 },
154
155 #[error(
157 "{provider} does not support {operation} (supported by: {}; route it via Providers::builder().route(...))",
158 join_providers(candidates)
159 )]
160 NotSupported {
161 provider: Provider,
163 operation: Operation,
165 candidates: Vec<Provider>,
168 },
169
170 #[error(
172 "no provider available for {operation} (supported by: {}; route it via Providers::builder().route(...))",
173 join_providers(candidates)
174 )]
175 NoProviderAvailable {
176 operation: Capability,
178 candidates: Vec<Provider>,
181 },
182
183 #[error(
185 "no adapter registered for {provider}; register it with \
186 ProvidersBuilder::with_adapter(..) before routing to it"
187 )]
188 ProviderNotRegistered {
189 provider: Provider,
191 },
192
193 #[cfg(feature = "translation")]
195 #[error("Translation error: {context}")]
196 TranslationError {
197 context: String,
199 },
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum ErrorCategory {
205 Auth,
207 RateLimit,
209 Timeout,
211 Server,
213 NotFound,
215 Validation,
217 Parsing,
219 Other,
221}
222
223pub type Result<T> = std::result::Result<T, FinanceError>;
225
226impl FinanceError {
227 pub fn is_retriable(&self) -> bool {
229 matches!(
230 self,
231 FinanceError::Timeout { .. }
232 | FinanceError::RateLimited { .. }
233 | FinanceError::HttpError(_)
234 | FinanceError::NetworkError { .. }
235 | FinanceError::AuthenticationFailed { .. }
236 | FinanceError::ServerError { .. }
237 ) || matches!(self, FinanceError::ExternalApiError { status, .. } if *status >= 500)
238 }
239
240 pub fn is_auth_error(&self) -> bool {
242 matches!(self, FinanceError::AuthenticationFailed { .. })
243 }
244
245 pub fn is_not_found(&self) -> bool {
247 matches!(self, FinanceError::SymbolNotFound { .. })
248 }
249
250 pub fn retry_after_secs(&self) -> Option<u64> {
252 match self {
253 Self::RateLimited { retry_after } => *retry_after,
254 Self::Timeout { .. } => Some(2),
255 Self::ServerError { status, .. } if *status >= 500 => Some(5),
256 Self::AuthenticationFailed { .. } => Some(1),
257 _ => None,
258 }
259 }
260
261 pub fn category(&self) -> ErrorCategory {
263 match self {
264 Self::AuthenticationFailed { .. } => ErrorCategory::Auth,
265 Self::RateLimited { .. } => ErrorCategory::RateLimit,
266 Self::Timeout { .. } => ErrorCategory::Timeout,
267 Self::ServerError { .. } => ErrorCategory::Server,
268 Self::SymbolNotFound { .. } => ErrorCategory::NotFound,
269 Self::InvalidParameter { .. } => ErrorCategory::Validation,
270 Self::JsonParseError(_)
271 | Self::ResponseStructureError { .. }
272 | Self::MacroDataError { .. }
273 | Self::FeedParseError { .. } => ErrorCategory::Parsing,
274 Self::NotSupported { .. } | Self::NoProviderAvailable { .. } => {
275 ErrorCategory::Validation
276 }
277 Self::ExternalApiError { .. } => ErrorCategory::Server,
278 _ => ErrorCategory::Other,
279 }
280 }
281
282 pub fn with_symbol(mut self, symbol: impl Into<String>) -> Self {
284 if let Self::SymbolNotFound {
285 symbol: ref mut s, ..
286 } = self
287 {
288 *s = Some(symbol.into());
289 }
290 self
291 }
292
293 pub fn with_context(mut self, context: impl Into<String>) -> Self {
295 match self {
296 Self::AuthenticationFailed {
297 context: ref mut c, ..
298 } => {
299 *c = context.into();
300 }
301 Self::SymbolNotFound {
302 context: ref mut c, ..
303 } => {
304 *c = context.into();
305 }
306 Self::ResponseStructureError {
307 context: ref mut c, ..
308 } => {
309 *c = context.into();
310 }
311 Self::ServerError {
312 context: ref mut c, ..
313 } => {
314 *c = context.into();
315 }
316 Self::MacroDataError {
317 context: ref mut c, ..
318 } => {
319 *c = context.into();
320 }
321 Self::FeedParseError {
322 context: ref mut c, ..
323 } => {
324 *c = context.into();
325 }
326 _ => {}
327 }
328 self
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn test_error_is_retriable() {
338 assert!(FinanceError::Timeout { timeout_ms: 5000 }.is_retriable());
339 assert!(FinanceError::RateLimited { retry_after: None }.is_retriable());
340 assert!(
341 FinanceError::AuthenticationFailed {
342 context: "test".to_string()
343 }
344 .is_retriable()
345 );
346 assert!(
347 FinanceError::ServerError {
348 status: 500,
349 context: "test".to_string()
350 }
351 .is_retriable()
352 );
353 assert!(
354 !FinanceError::SymbolNotFound {
355 symbol: Some("AAPL".to_string()),
356 context: "test".to_string()
357 }
358 .is_retriable()
359 );
360 assert!(
361 !FinanceError::InvalidParameter {
362 param: "test".to_string(),
363 reason: "invalid".to_string()
364 }
365 .is_retriable()
366 );
367 }
368
369 #[test]
370 fn test_error_is_auth_error() {
371 assert!(
372 FinanceError::AuthenticationFailed {
373 context: "test".to_string()
374 }
375 .is_auth_error()
376 );
377 assert!(!FinanceError::Timeout { timeout_ms: 5000 }.is_auth_error());
378 }
379
380 #[test]
381 fn test_error_is_not_found() {
382 assert!(
383 FinanceError::SymbolNotFound {
384 symbol: Some("AAPL".to_string()),
385 context: "test".to_string()
386 }
387 .is_not_found()
388 );
389 assert!(!FinanceError::Timeout { timeout_ms: 5000 }.is_not_found());
390 }
391
392 #[test]
393 fn test_retry_after_secs() {
394 assert_eq!(
395 FinanceError::RateLimited {
396 retry_after: Some(10)
397 }
398 .retry_after_secs(),
399 Some(10)
400 );
401 assert_eq!(
402 FinanceError::Timeout { timeout_ms: 5000 }.retry_after_secs(),
403 Some(2)
404 );
405 assert_eq!(
406 FinanceError::ServerError {
407 status: 503,
408 context: "test".to_string()
409 }
410 .retry_after_secs(),
411 Some(5)
412 );
413 assert_eq!(
414 FinanceError::SymbolNotFound {
415 symbol: None,
416 context: "test".to_string()
417 }
418 .retry_after_secs(),
419 None
420 );
421 }
422
423 #[test]
424 fn test_error_category() {
425 assert_eq!(
426 FinanceError::AuthenticationFailed {
427 context: "test".to_string()
428 }
429 .category(),
430 ErrorCategory::Auth
431 );
432 assert_eq!(
433 FinanceError::RateLimited { retry_after: None }.category(),
434 ErrorCategory::RateLimit
435 );
436 assert_eq!(
437 FinanceError::Timeout { timeout_ms: 5000 }.category(),
438 ErrorCategory::Timeout
439 );
440 assert_eq!(
441 FinanceError::SymbolNotFound {
442 symbol: None,
443 context: "test".to_string()
444 }
445 .category(),
446 ErrorCategory::NotFound
447 );
448 }
449
450 #[test]
451 fn test_with_symbol() {
452 let error = FinanceError::SymbolNotFound {
453 symbol: None,
454 context: "test".to_string(),
455 }
456 .with_symbol("AAPL");
457
458 if let FinanceError::SymbolNotFound { symbol, .. } = error {
459 assert_eq!(symbol, Some("AAPL".to_string()));
460 } else {
461 panic!("Expected SymbolNotFound");
462 }
463 }
464
465 #[test]
466 fn test_with_context() {
467 let error = FinanceError::AuthenticationFailed {
468 context: "old".to_string(),
469 }
470 .with_context("new context");
471
472 if let FinanceError::AuthenticationFailed { context } = error {
473 assert_eq!(context, "new context");
474 } else {
475 panic!("Expected AuthenticationFailed");
476 }
477 }
478}