1use std::io;
4use thiserror::Error;
5use tokio::task::JoinError;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum TransportErrorKind {
10 Timeout,
12 Connect,
14 HttpStatus,
16 ResponseBody,
18 Other,
20}
21
22#[derive(Error, Debug, Clone)]
24pub enum DukascopyError {
25 #[error("Transport error ({kind:?}, status={status:?}): {message}")]
27 Transport {
28 kind: TransportErrorKind,
30 status: Option<u16>,
32 message: String,
34 },
35
36 #[error("LZMA decompression error: {0}")]
38 LzmaError(String),
39
40 #[error("Invalid tick data: data is malformed or contains invalid values")]
42 InvalidTickData,
43
44 #[error("Invalid currency code '{code}': {reason}")]
46 InvalidCurrencyCode {
47 code: String,
49 reason: String,
51 },
52
53 #[error("Data not found for {pair} at {timestamp}")]
55 DataNotFoundFor {
56 pair: String,
58 timestamp: String,
60 },
61
62 #[error("Data not found for the specified time")]
64 DataNotFound,
65
66 #[error("Rate limit exceeded. Please wait before making more requests.")]
68 RateLimitExceeded,
69
70 #[error("Unauthorized access")]
72 Unauthorized,
73
74 #[error("Access forbidden")]
76 Forbidden,
77
78 #[error("Invalid request: {0}")]
80 InvalidRequest(String),
81
82 #[error("Missing default quote currency in client configuration")]
84 MissingDefaultQuoteCurrency,
85
86 #[error("Symbol-only pair resolution is disabled in client configuration")]
88 PairResolutionDisabled,
89
90 #[error("No conversion route found for {symbol}/{quote}")]
92 NoConversionRoute { symbol: String, quote: String },
93
94 #[error("Request timed out after {0} seconds")]
96 Timeout(u64),
97
98 #[error("Cache error: {0}")]
100 CacheError(String),
101
102 #[error("Unknown error: {0}")]
104 Unknown(String),
105}
106
107impl DukascopyError {
108 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 pub fn is_not_found(&self) -> bool {
130 matches!(self, Self::DataNotFound | Self::DataNotFoundFor { .. })
131 }
132
133 pub fn is_validation_error(&self) -> bool {
135 matches!(
136 self,
137 Self::InvalidCurrencyCode { .. } | Self::InvalidTickData | Self::InvalidRequest(_)
138 )
139 }
140
141 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}