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/// Errors that can occur when using the Dukascopy FX library.
8#[derive(Error, Debug)]
9pub enum DukascopyError {
10    /// HTTP request failed
11    #[error("HTTP error: {0}")]
12    HttpError(String),
13
14    /// LZMA decompression failed
15    #[error("LZMA decompression error: {0}")]
16    LzmaError(String),
17
18    /// Tick data is malformed or invalid
19    #[error("Invalid tick data: data is malformed or contains invalid values")]
20    InvalidTickData,
21
22    /// Invalid currency code provided
23    #[error("Invalid currency code '{code}': {reason}")]
24    InvalidCurrencyCode {
25        /// The invalid currency code
26        code: String,
27        /// Reason why it's invalid
28        reason: String,
29    },
30
31    /// Attempted to access market during closed hours
32    #[error("Market is closed: {0}")]
33    MarketClosed(String),
34
35    /// No data available for the requested time/pair
36    #[error("Data not found for {pair} at {timestamp}")]
37    DataNotFoundFor {
38        /// The currency pair requested
39        pair: String,
40        /// The timestamp requested
41        timestamp: String,
42    },
43
44    /// Generic data not found (for backward compatibility)
45    #[error("Data not found for the specified time")]
46    DataNotFound,
47
48    /// API rate limit exceeded
49    #[error("Rate limit exceeded. Please wait before making more requests.")]
50    RateLimitExceeded,
51
52    /// Unauthorized access (HTTP 401)
53    #[error("Unauthorized access")]
54    Unauthorized,
55
56    /// Forbidden access (HTTP 403)
57    #[error("Access forbidden")]
58    Forbidden,
59
60    /// Invalid request (HTTP 400)
61    #[error("Invalid request: {0}")]
62    InvalidRequest(String),
63
64    /// Request timeout
65    #[error("Request timed out after {0} seconds")]
66    Timeout(u64),
67
68    /// Cache error
69    #[error("Cache error: {0}")]
70    CacheError(String),
71
72    /// Unknown error with context
73    #[error("Unknown error: {0}")]
74    Unknown(String),
75}
76
77impl DukascopyError {
78    /// Returns true if this error is retryable.
79    ///
80    /// Retryable errors are transient and may succeed on retry:
81    /// - Rate limiting
82    /// - Timeouts
83    /// - Some HTTP errors
84    pub fn is_retryable(&self) -> bool {
85        matches!(
86            self,
87            Self::RateLimitExceeded | Self::Timeout(_) | Self::HttpError(_)
88        )
89    }
90
91    /// Returns true if this error indicates the data doesn't exist.
92    pub fn is_not_found(&self) -> bool {
93        matches!(self, Self::DataNotFound | Self::DataNotFoundFor { .. })
94    }
95
96    /// Returns true if this error is due to invalid input.
97    pub fn is_validation_error(&self) -> bool {
98        matches!(
99            self,
100            Self::InvalidCurrencyCode { .. } | Self::InvalidTickData | Self::InvalidRequest(_)
101        )
102    }
103}
104
105impl From<reqwest::Error> for DukascopyError {
106    fn from(err: reqwest::Error) -> Self {
107        if err.is_timeout() {
108            DukascopyError::Timeout(30)
109        } else if err.is_connect() {
110            DukascopyError::HttpError(format!("Connection failed: {}", err))
111        } else {
112            DukascopyError::HttpError(err.to_string())
113        }
114    }
115}
116
117impl From<lzma_rs::error::Error> for DukascopyError {
118    fn from(err: lzma_rs::error::Error) -> Self {
119        DukascopyError::LzmaError(err.to_string())
120    }
121}
122
123impl From<io::Error> for DukascopyError {
124    fn from(err: io::Error) -> Self {
125        match err.kind() {
126            io::ErrorKind::TimedOut => DukascopyError::Timeout(30),
127            io::ErrorKind::NotFound => DukascopyError::DataNotFound,
128            _ => DukascopyError::Unknown(format!("IO error: {}", err)),
129        }
130    }
131}
132
133impl From<JoinError> for DukascopyError {
134    fn from(err: JoinError) -> Self {
135        if err.is_cancelled() {
136            DukascopyError::Unknown("Task was cancelled".to_string())
137        } else {
138            DukascopyError::Unknown(format!("Task panicked: {}", err))
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn test_is_retryable() {
149        assert!(DukascopyError::RateLimitExceeded.is_retryable());
150        assert!(DukascopyError::Timeout(30).is_retryable());
151        assert!(DukascopyError::HttpError("test".into()).is_retryable());
152
153        assert!(!DukascopyError::InvalidTickData.is_retryable());
154        assert!(!DukascopyError::DataNotFound.is_retryable());
155    }
156
157    #[test]
158    fn test_is_not_found() {
159        assert!(DukascopyError::DataNotFound.is_not_found());
160        assert!(DukascopyError::DataNotFoundFor {
161            pair: "EUR/USD".into(),
162            timestamp: "2024-01-01".into()
163        }
164        .is_not_found());
165
166        assert!(!DukascopyError::InvalidTickData.is_not_found());
167    }
168
169    #[test]
170    fn test_is_validation_error() {
171        assert!(DukascopyError::InvalidTickData.is_validation_error());
172        assert!(DukascopyError::InvalidCurrencyCode {
173            code: "XX".into(),
174            reason: "too short".into()
175        }
176        .is_validation_error());
177
178        assert!(!DukascopyError::DataNotFound.is_validation_error());
179    }
180
181    #[test]
182    fn test_error_display() {
183        let err = DukascopyError::InvalidCurrencyCode {
184            code: "XX".into(),
185            reason: "must be 3 characters".into(),
186        };
187        assert_eq!(
188            err.to_string(),
189            "Invalid currency code 'XX': must be 3 characters"
190        );
191
192        let err = DukascopyError::DataNotFoundFor {
193            pair: "EUR/USD".into(),
194            timestamp: "2024-01-01 12:00:00".into(),
195        };
196        assert!(err.to_string().contains("EUR/USD"));
197        assert!(err.to_string().contains("2024-01-01"));
198    }
199}