use std::io;
use thiserror::Error;
use tokio::task::JoinError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransportErrorKind {
Timeout,
Connect,
HttpStatus,
ResponseBody,
Other,
}
#[derive(Error, Debug, Clone)]
pub enum DukascopyError {
#[error("Transport error ({kind:?}, status={status:?}): {message}")]
Transport {
kind: TransportErrorKind,
status: Option<u16>,
message: String,
},
#[error("LZMA decompression error: {0}")]
LzmaError(String),
#[error("Invalid tick data: data is malformed or contains invalid values")]
InvalidTickData,
#[error("Invalid currency code '{code}': {reason}")]
InvalidCurrencyCode {
code: String,
reason: String,
},
#[error("Data not found for {pair} at {timestamp}")]
DataNotFoundFor {
pair: String,
timestamp: String,
},
#[error("Data not found for the specified time")]
DataNotFound,
#[error("Rate limit exceeded. Please wait before making more requests.")]
RateLimitExceeded,
#[error("Unauthorized access")]
Unauthorized,
#[error("Access forbidden")]
Forbidden,
#[error("Invalid request: {0}")]
InvalidRequest(String),
#[error("Missing default quote currency in client configuration")]
MissingDefaultQuoteCurrency,
#[error("Symbol-only pair resolution is disabled in client configuration")]
PairResolutionDisabled,
#[error("No conversion route found for {symbol}/{quote}")]
NoConversionRoute { symbol: String, quote: String },
#[error("Request timed out after {0} seconds")]
Timeout(u64),
#[error("Cache error: {0}")]
CacheError(String),
#[error("Unknown error: {0}")]
Unknown(String),
}
impl DukascopyError {
pub fn is_retryable(&self) -> bool {
match self {
Self::RateLimitExceeded | Self::Timeout(_) => true,
Self::Transport { kind, status, .. } => match kind {
TransportErrorKind::Timeout | TransportErrorKind::Connect => true,
TransportErrorKind::HttpStatus => status
.map(|code| code == 429 || (500..=599).contains(&code))
.unwrap_or(false),
TransportErrorKind::ResponseBody | TransportErrorKind::Other => true,
},
_ => false,
}
}
pub fn is_not_found(&self) -> bool {
matches!(self, Self::DataNotFound | Self::DataNotFoundFor { .. })
}
pub fn is_validation_error(&self) -> bool {
matches!(
self,
Self::InvalidCurrencyCode { .. } | Self::InvalidTickData | Self::InvalidRequest(_)
)
}
pub fn is_configuration_error(&self) -> bool {
matches!(
self,
Self::MissingDefaultQuoteCurrency
| Self::PairResolutionDisabled
| Self::NoConversionRoute { .. }
)
}
}
impl From<reqwest::Error> for DukascopyError {
fn from(err: reqwest::Error) -> Self {
if err.is_timeout() {
DukascopyError::Timeout(30)
} else if err.is_connect() {
DukascopyError::Transport {
kind: TransportErrorKind::Connect,
status: None,
message: err.to_string(),
}
} else {
DukascopyError::Transport {
kind: TransportErrorKind::Other,
status: err.status().map(|status| status.as_u16()),
message: err.to_string(),
}
}
}
}
impl From<lzma_rs::error::Error> for DukascopyError {
fn from(err: lzma_rs::error::Error) -> Self {
DukascopyError::LzmaError(err.to_string())
}
}
impl From<io::Error> for DukascopyError {
fn from(err: io::Error) -> Self {
match err.kind() {
io::ErrorKind::TimedOut => DukascopyError::Timeout(30),
io::ErrorKind::NotFound => DukascopyError::DataNotFound,
_ => DukascopyError::Unknown(format!("IO error: {}", err)),
}
}
}
impl From<JoinError> for DukascopyError {
fn from(err: JoinError) -> Self {
if err.is_cancelled() {
DukascopyError::Unknown("Task was cancelled".to_string())
} else {
DukascopyError::Unknown(format!("Task panicked: {}", err))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_retryable() {
assert!(DukascopyError::RateLimitExceeded.is_retryable());
assert!(DukascopyError::Timeout(30).is_retryable());
assert!(DukascopyError::Transport {
kind: TransportErrorKind::Connect,
status: None,
message: "connect".into()
}
.is_retryable());
assert!(DukascopyError::Transport {
kind: TransportErrorKind::HttpStatus,
status: Some(503),
message: "service unavailable".into()
}
.is_retryable());
assert!(!DukascopyError::Transport {
kind: TransportErrorKind::HttpStatus,
status: Some(404),
message: "not found".into()
}
.is_retryable());
assert!(!DukascopyError::InvalidTickData.is_retryable());
assert!(!DukascopyError::DataNotFound.is_retryable());
}
#[test]
fn test_is_not_found() {
assert!(DukascopyError::DataNotFound.is_not_found());
assert!(DukascopyError::DataNotFoundFor {
pair: "EUR/USD".into(),
timestamp: "2024-01-01".into()
}
.is_not_found());
assert!(!DukascopyError::InvalidTickData.is_not_found());
}
#[test]
fn test_is_validation_error() {
assert!(DukascopyError::InvalidTickData.is_validation_error());
assert!(DukascopyError::InvalidCurrencyCode {
code: "XX".into(),
reason: "too short".into()
}
.is_validation_error());
assert!(!DukascopyError::DataNotFound.is_validation_error());
}
#[test]
fn test_is_configuration_error() {
assert!(DukascopyError::MissingDefaultQuoteCurrency.is_configuration_error());
assert!(DukascopyError::PairResolutionDisabled.is_configuration_error());
assert!(DukascopyError::NoConversionRoute {
symbol: "AAPL".into(),
quote: "PLN".into()
}
.is_configuration_error());
assert!(!DukascopyError::DataNotFound.is_configuration_error());
}
#[test]
fn test_error_display() {
let err = DukascopyError::InvalidCurrencyCode {
code: "XX".into(),
reason: "must be 3 characters".into(),
};
assert_eq!(
err.to_string(),
"Invalid currency code 'XX': must be 3 characters"
);
let err = DukascopyError::DataNotFoundFor {
pair: "EUR/USD".into(),
timestamp: "2024-01-01 12:00:00".into(),
};
assert!(err.to_string().contains("EUR/USD"));
assert!(err.to_string().contains("2024-01-01"));
}
}