Skip to main content

dukascopy_fx/
error.rs

1//! Error types for the Dukascopy FX library.
2
3use std::io;
4use thiserror::Error;
5use tokio::task::JoinError;
6
7/// Transport-layer error category for machine-actionable handling.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum TransportErrorKind {
10    /// Request timed out.
11    Timeout,
12    /// Connection establishment failed.
13    Connect,
14    /// Non-success HTTP status code not covered by dedicated variants.
15    HttpStatus,
16    /// Failed reading HTTP response body.
17    ResponseBody,
18    /// Other transport-level request/response failure.
19    Other,
20}
21
22/// Errors that can occur when using the Dukascopy FX library.
23#[derive(Error, Debug, Clone)]
24pub enum DukascopyError {
25    /// Structured transport/network error.
26    #[error("Transport error ({kind:?}, status={status:?}): {message}")]
27    Transport {
28        /// Transport error category.
29        kind: TransportErrorKind,
30        /// Optional HTTP status code for status-based failures.
31        status: Option<u16>,
32        /// Human-readable transport error details.
33        message: String,
34    },
35
36    /// LZMA decompression failed
37    #[error("LZMA decompression error: {0}")]
38    LzmaError(String),
39
40    /// Tick data is malformed or invalid
41    #[error("Invalid tick data: data is malformed or contains invalid values")]
42    InvalidTickData,
43
44    /// Invalid currency code provided
45    #[error("Invalid currency code '{code}': {reason}")]
46    InvalidCurrencyCode {
47        /// The invalid currency code
48        code: String,
49        /// Reason why it's invalid
50        reason: String,
51    },
52
53    /// No data available for the requested time/pair
54    #[error("Data not found for {pair} at {timestamp}")]
55    DataNotFoundFor {
56        /// The currency pair requested
57        pair: String,
58        /// The timestamp requested
59        timestamp: String,
60    },
61
62    /// Generic data not found (for backward compatibility)
63    #[error("Data not found for the specified time")]
64    DataNotFound,
65
66    /// API rate limit exceeded
67    #[error("Rate limit exceeded. Please wait before making more requests.")]
68    RateLimitExceeded,
69
70    /// Unauthorized access (HTTP 401)
71    #[error("Unauthorized access")]
72    Unauthorized,
73
74    /// Forbidden access (HTTP 403)
75    #[error("Access forbidden")]
76    Forbidden,
77
78    /// Invalid request (HTTP 400)
79    #[error("Invalid request: {0}")]
80    InvalidRequest(String),
81
82    /// Missing configured default quote currency for symbol-only request.
83    #[error("Missing default quote currency in client configuration")]
84    MissingDefaultQuoteCurrency,
85
86    /// Symbol-only resolution is disabled in client configuration.
87    #[error("Symbol-only pair resolution is disabled in client configuration")]
88    PairResolutionDisabled,
89
90    /// No available direct or synthetic route for symbol conversion.
91    #[error("No conversion route found for {symbol}/{quote}")]
92    NoConversionRoute { symbol: String, quote: String },
93
94    /// Request timeout
95    #[error("Request timed out after {0} seconds")]
96    Timeout(u64),
97
98    /// Cache error
99    #[error("Cache error: {0}")]
100    CacheError(String),
101
102    /// Unknown error with context
103    #[error("Unknown error: {0}")]
104    Unknown(String),
105}
106
107impl DukascopyError {
108    /// Returns true if this error is retryable.
109    ///
110    /// Retryable errors are transient and may succeed on retry:
111    /// - Rate limiting
112    /// - Timeouts
113    /// - Some HTTP errors
114    pub fn is_retryable(&self) -> bool {
115        match self {
116            Self::RateLimitExceeded | Self::Timeout(_) => true,
117            Self::Transport { kind, status, .. } => match kind {
118                TransportErrorKind::Timeout | TransportErrorKind::Connect => true,
119                TransportErrorKind::HttpStatus => status
120                    .map(|code| code == 429 || (500..=599).contains(&code))
121                    .unwrap_or(false),
122                TransportErrorKind::ResponseBody | TransportErrorKind::Other => true,
123            },
124            _ => false,
125        }
126    }
127
128    /// Returns true if this error indicates the data doesn't exist.
129    pub fn is_not_found(&self) -> bool {
130        matches!(self, Self::DataNotFound | Self::DataNotFoundFor { .. })
131    }
132
133    /// Returns true if this error is due to invalid input.
134    pub fn is_validation_error(&self) -> bool {
135        matches!(
136            self,
137            Self::InvalidCurrencyCode { .. } | Self::InvalidTickData | Self::InvalidRequest(_)
138        )
139    }
140
141    /// Returns true if error is caused by client configuration.
142    pub fn is_configuration_error(&self) -> bool {
143        matches!(
144            self,
145            Self::MissingDefaultQuoteCurrency
146                | Self::PairResolutionDisabled
147                | Self::NoConversionRoute { .. }
148        )
149    }
150}
151
152impl From<reqwest::Error> for DukascopyError {
153    fn from(err: reqwest::Error) -> Self {
154        if err.is_timeout() {
155            DukascopyError::Timeout(30)
156        } else if err.is_connect() {
157            DukascopyError::Transport {
158                kind: TransportErrorKind::Connect,
159                status: None,
160                message: err.to_string(),
161            }
162        } else {
163            DukascopyError::Transport {
164                kind: TransportErrorKind::Other,
165                status: err.status().map(|status| status.as_u16()),
166                message: err.to_string(),
167            }
168        }
169    }
170}
171
172impl From<lzma_rs::error::Error> for DukascopyError {
173    fn from(err: lzma_rs::error::Error) -> Self {
174        DukascopyError::LzmaError(err.to_string())
175    }
176}
177
178impl From<io::Error> for DukascopyError {
179    fn from(err: io::Error) -> Self {
180        match err.kind() {
181            io::ErrorKind::TimedOut => DukascopyError::Timeout(30),
182            io::ErrorKind::NotFound => DukascopyError::DataNotFound,
183            _ => DukascopyError::Unknown(format!("IO error: {}", err)),
184        }
185    }
186}
187
188impl From<JoinError> for DukascopyError {
189    fn from(err: JoinError) -> Self {
190        if err.is_cancelled() {
191            DukascopyError::Unknown("Task was cancelled".to_string())
192        } else {
193            DukascopyError::Unknown(format!("Task panicked: {}", err))
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn test_is_retryable() {
204        assert!(DukascopyError::RateLimitExceeded.is_retryable());
205        assert!(DukascopyError::Timeout(30).is_retryable());
206        assert!(DukascopyError::Transport {
207            kind: TransportErrorKind::Connect,
208            status: None,
209            message: "connect".into()
210        }
211        .is_retryable());
212        assert!(DukascopyError::Transport {
213            kind: TransportErrorKind::HttpStatus,
214            status: Some(503),
215            message: "service unavailable".into()
216        }
217        .is_retryable());
218        assert!(!DukascopyError::Transport {
219            kind: TransportErrorKind::HttpStatus,
220            status: Some(404),
221            message: "not found".into()
222        }
223        .is_retryable());
224
225        assert!(!DukascopyError::InvalidTickData.is_retryable());
226        assert!(!DukascopyError::DataNotFound.is_retryable());
227    }
228
229    #[test]
230    fn test_is_not_found() {
231        assert!(DukascopyError::DataNotFound.is_not_found());
232        assert!(DukascopyError::DataNotFoundFor {
233            pair: "EUR/USD".into(),
234            timestamp: "2024-01-01".into()
235        }
236        .is_not_found());
237
238        assert!(!DukascopyError::InvalidTickData.is_not_found());
239    }
240
241    #[test]
242    fn test_is_validation_error() {
243        assert!(DukascopyError::InvalidTickData.is_validation_error());
244        assert!(DukascopyError::InvalidCurrencyCode {
245            code: "XX".into(),
246            reason: "too short".into()
247        }
248        .is_validation_error());
249
250        assert!(!DukascopyError::DataNotFound.is_validation_error());
251    }
252
253    #[test]
254    fn test_is_configuration_error() {
255        assert!(DukascopyError::MissingDefaultQuoteCurrency.is_configuration_error());
256        assert!(DukascopyError::PairResolutionDisabled.is_configuration_error());
257        assert!(DukascopyError::NoConversionRoute {
258            symbol: "AAPL".into(),
259            quote: "PLN".into()
260        }
261        .is_configuration_error());
262        assert!(!DukascopyError::DataNotFound.is_configuration_error());
263    }
264
265    #[test]
266    fn test_error_display() {
267        let err = DukascopyError::InvalidCurrencyCode {
268            code: "XX".into(),
269            reason: "must be 3 characters".into(),
270        };
271        assert_eq!(
272            err.to_string(),
273            "Invalid currency code 'XX': must be 3 characters"
274        );
275
276        let err = DukascopyError::DataNotFoundFor {
277            pair: "EUR/USD".into(),
278            timestamp: "2024-01-01 12:00:00".into(),
279        };
280        assert!(err.to_string().contains("EUR/USD"));
281        assert!(err.to_string().contains("2024-01-01"));
282    }
283}