use std::time::Duration;
use nautilus_network::http::{HttpClientError, StatusCode};
use thiserror::Error;
use tokio_tungstenite::tungstenite;
use crate::http::error::BitmexBuildError;
#[derive(Debug, Error)]
pub enum BitmexError {
#[error("Retryable error: {source}")]
Retryable {
#[source]
source: BitmexRetryableError,
retry_after: Option<Duration>,
},
#[error("Non-retryable error: {source}")]
NonRetryable {
#[source]
source: BitmexNonRetryableError,
},
#[error("Fatal error: {source}")]
Fatal {
#[source]
source: BitmexFatalError,
},
#[error("Network error: {0}")]
Network(#[from] HttpClientError),
#[error("WebSocket error: {0}")]
WebSocket(#[from] tungstenite::Error),
#[error("JSON error: {message}")]
Json {
message: String,
raw: Option<String>,
},
#[error("Configuration error: {0}")]
Config(String),
}
#[derive(Debug, Error)]
pub enum BitmexRetryableError {
#[error("Rate limit exceeded (remaining: {remaining:?}, reset: {reset_at:?})")]
RateLimit {
remaining: Option<u32>,
reset_at: Option<Duration>,
},
#[error("Service temporarily unavailable")]
ServiceUnavailable,
#[error("Gateway timeout")]
GatewayTimeout,
#[error("Server error (status: {status})")]
ServerError { status: StatusCode },
#[error("Request timed out after {duration:?}")]
Timeout { duration: Duration },
#[error("Temporary network error: {message}")]
TemporaryNetwork { message: String },
#[error("WebSocket connection lost")]
ConnectionLost,
#[error("Order book resync required for {symbol}")]
OrderBookResync { symbol: String },
}
#[derive(Debug, Error)]
pub enum BitmexNonRetryableError {
#[error("Bad request: {message}")]
BadRequest { message: String },
#[error("Resource not found: {resource}")]
NotFound { resource: String },
#[error("Method not allowed: {method}")]
MethodNotAllowed { method: String },
#[error("Validation error: {field}: {message}")]
Validation { field: String, message: String },
#[error("Invalid order: {message}")]
InvalidOrder { message: String },
#[error("Insufficient balance: {available} < {required}")]
InsufficientBalance { available: String, required: String },
#[error("Invalid symbol: {symbol}")]
InvalidSymbol { symbol: String },
#[error("Invalid request format: {message}")]
InvalidRequest { message: String },
#[error("Missing required parameter: {param}")]
MissingParameter { param: String },
#[error("Order not found: {order_id}")]
OrderNotFound { order_id: String },
#[error("Position not found: {symbol}")]
PositionNotFound { symbol: String },
}
#[derive(Debug, Error)]
pub enum BitmexFatalError {
#[error("Authentication failed: {message}")]
AuthenticationFailed { message: String },
#[error("Forbidden: {message}")]
Forbidden { message: String },
#[error("Account suspended: {reason}")]
AccountSuspended { reason: String },
#[error("Invalid API credentials")]
InvalidCredentials,
#[error("API version no longer supported")]
ApiVersionDeprecated,
#[error("Critical invariant violation: {invariant}")]
InvariantViolation { invariant: String },
}
impl BitmexError {
pub fn from_rate_limit_headers(
remaining: Option<&str>,
reset: Option<&str>,
retry_after: Option<&str>,
) -> Self {
let remaining = remaining.and_then(|s| s.parse().ok());
let reset_at = reset.and_then(|s| {
s.parse::<u64>().ok().and_then(|timestamp| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
if timestamp > now {
Some(Duration::from_secs(timestamp - now))
} else {
Some(Duration::from_secs(0))
}
})
});
let retry_duration = retry_after
.and_then(|s| s.parse::<u64>().ok().map(Duration::from_secs))
.or(reset_at);
Self::Retryable {
source: BitmexRetryableError::RateLimit {
remaining,
reset_at,
},
retry_after: retry_duration,
}
}
pub fn from_http_status(status: StatusCode, message: Option<String>) -> Self {
match status {
StatusCode::BAD_REQUEST => Self::NonRetryable {
source: BitmexNonRetryableError::BadRequest {
message: message.unwrap_or_else(|| "Bad request".to_string()),
},
},
StatusCode::UNAUTHORIZED => Self::Fatal {
source: BitmexFatalError::AuthenticationFailed {
message: message.unwrap_or_else(|| "Unauthorized".to_string()),
},
},
StatusCode::FORBIDDEN => Self::Fatal {
source: BitmexFatalError::Forbidden {
message: message.unwrap_or_else(|| "Forbidden".to_string()),
},
},
StatusCode::NOT_FOUND => Self::NonRetryable {
source: BitmexNonRetryableError::NotFound {
resource: message.unwrap_or_else(|| "Resource".to_string()),
},
},
StatusCode::METHOD_NOT_ALLOWED => Self::NonRetryable {
source: BitmexNonRetryableError::MethodNotAllowed {
method: message.unwrap_or_else(|| "Method".to_string()),
},
},
StatusCode::TOO_MANY_REQUESTS => Self::from_rate_limit_headers(None, None, None),
StatusCode::SERVICE_UNAVAILABLE => Self::Retryable {
source: BitmexRetryableError::ServiceUnavailable,
retry_after: None,
},
StatusCode::GATEWAY_TIMEOUT => Self::Retryable {
source: BitmexRetryableError::GatewayTimeout,
retry_after: None,
},
s if s.is_server_error() => Self::Retryable {
source: BitmexRetryableError::ServerError { status },
retry_after: None,
},
_ => Self::NonRetryable {
source: BitmexNonRetryableError::InvalidRequest {
message: format!("Unexpected status: {status}"),
},
},
}
}
#[must_use]
pub fn is_retryable(&self) -> bool {
matches!(self, Self::Retryable { .. })
}
#[must_use]
pub fn is_fatal(&self) -> bool {
matches!(self, Self::Fatal { .. })
}
#[must_use]
pub fn retry_after(&self) -> Option<Duration> {
match self {
Self::Retryable { retry_after, .. } => *retry_after,
_ => None,
}
}
}
impl From<serde_json::Error> for BitmexError {
fn from(error: serde_json::Error) -> Self {
Self::Json {
message: error.to_string(),
raw: None,
}
}
}
impl From<BitmexBuildError> for BitmexError {
fn from(error: BitmexBuildError) -> Self {
Self::NonRetryable {
source: BitmexNonRetryableError::Validation {
field: "parameters".to_string(),
message: error.to_string(),
},
}
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
fn test_error_classification() {
let err = BitmexError::from_http_status(StatusCode::TOO_MANY_REQUESTS, None);
assert!(err.is_retryable());
assert!(!err.is_fatal());
let err = BitmexError::from_http_status(StatusCode::UNAUTHORIZED, None);
assert!(!err.is_retryable());
assert!(err.is_fatal());
let err = BitmexError::from_http_status(StatusCode::BAD_REQUEST, None);
assert!(!err.is_retryable());
assert!(!err.is_fatal());
}
#[rstest]
fn test_rate_limit_parsing() {
let future_timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 60;
let err = BitmexError::from_rate_limit_headers(
Some("10"),
Some(&future_timestamp.to_string()),
None,
);
match err {
BitmexError::Retryable {
source: BitmexRetryableError::RateLimit { remaining, .. },
retry_after,
..
} => {
assert_eq!(remaining, Some(10));
assert!(retry_after.is_some());
let duration = retry_after.unwrap();
assert!(duration.as_secs() >= 59 && duration.as_secs() <= 61);
}
_ => panic!("Expected rate limit error"),
}
}
#[rstest]
fn test_rate_limit_with_retry_after() {
let err = BitmexError::from_rate_limit_headers(Some("0"), None, Some("30"));
match err {
BitmexError::Retryable {
source: BitmexRetryableError::RateLimit { remaining, .. },
retry_after,
..
} => {
assert_eq!(remaining, Some(0));
assert_eq!(retry_after, Some(Duration::from_secs(30)));
}
_ => panic!("Expected rate limit error"),
}
}
#[rstest]
fn test_retry_after() {
let err = BitmexError::Retryable {
source: BitmexRetryableError::RateLimit {
remaining: Some(0),
reset_at: Some(Duration::from_secs(60)),
},
retry_after: Some(Duration::from_secs(60)),
};
assert_eq!(err.retry_after(), Some(Duration::from_secs(60)));
}
}