use nautilus_network::http::{HttpClientError, ReqwestError, StatusCode};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("transport error: {0}")]
Transport(String),
#[error("serde error: {0}")]
Serde(#[from] serde_json::Error),
#[error("auth error: {0}")]
Auth(String),
#[error("Rate limited on {scope} (weight={weight}) retry_after_ms={retry_after_ms:?}")]
RateLimit {
scope: &'static str,
weight: u32,
retry_after_ms: Option<u64>,
},
#[error("nonce window error: {0}")]
NonceWindow(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("exchange error: {0}")]
Exchange(String),
#[error("timeout")]
Timeout,
#[error("decode error: {0}")]
Decode(String),
#[error("invariant violated: {0}")]
Invariant(&'static str),
#[error("HTTP error {status}: {message}")]
Http { status: u16, message: String },
#[error("URL parse error: {0}")]
UrlParse(#[from] url::ParseError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
impl Error {
pub fn transport(msg: impl Into<String>) -> Self {
Self::Transport(msg.into())
}
pub fn auth(msg: impl Into<String>) -> Self {
Self::Auth(msg.into())
}
pub fn rate_limit(scope: &'static str, weight: u32, retry_after_ms: Option<u64>) -> Self {
Self::RateLimit {
scope,
weight,
retry_after_ms,
}
}
pub fn nonce_window(msg: impl Into<String>) -> Self {
Self::NonceWindow(msg.into())
}
pub fn bad_request(msg: impl Into<String>) -> Self {
Self::BadRequest(msg.into())
}
pub fn exchange(msg: impl Into<String>) -> Self {
Self::Exchange(msg.into())
}
pub fn decode(msg: impl Into<String>) -> Self {
Self::Decode(msg.into())
}
pub fn http(status: u16, message: impl Into<String>) -> Self {
Self::Http {
status,
message: message.into(),
}
}
pub fn from_http_status(status: StatusCode, body: &[u8]) -> Self {
let message = String::from_utf8_lossy(body).to_string();
match status.as_u16() {
401 | 403 => Self::auth(format!("HTTP {}: {}", status.as_u16(), message)),
400 => Self::bad_request(format!("HTTP {}: {}", status.as_u16(), message)),
429 => Self::rate_limit("unknown", 0, None),
500..=599 => Self::exchange(format!("HTTP {}: {}", status.as_u16(), message)),
_ => Self::http(status.as_u16(), message),
}
}
#[allow(clippy::needless_pass_by_value)]
pub fn from_reqwest(error: ReqwestError) -> Self {
if error.is_timeout() {
Self::Timeout
} else if let Some(status) = error.status() {
let status_code = status.as_u16();
match status_code {
401 | 403 => Self::auth(format!("HTTP {status_code}: authentication failed")),
400 => Self::bad_request(format!("HTTP {status_code}: bad request")),
429 => Self::rate_limit("unknown", 0, None),
500..=599 => Self::exchange(format!("HTTP {status_code}: server error")),
_ => Self::http(status_code, format!("HTTP error: {error}")),
}
} else if error.is_connect() || error.is_request() {
Self::transport(format!("Request error: {error}"))
} else {
Self::transport(format!("Unknown reqwest error: {error}"))
}
}
#[allow(clippy::needless_pass_by_value)]
pub fn from_http_client(error: HttpClientError) -> Self {
Self::transport(format!("HTTP client error: {error}"))
}
pub fn is_retryable(&self) -> bool {
match self {
Self::Transport(_) | Self::Timeout | Self::RateLimit { .. } => true,
Self::Http { status, .. } => *status >= 500,
_ => false,
}
}
pub fn is_rate_limited(&self) -> bool {
matches!(self, Self::RateLimit { .. })
}
pub fn is_auth_error(&self) -> bool {
matches!(self, Self::Auth(_))
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
fn test_error_constructors() {
let transport_err = Error::transport("Connection failed");
assert!(matches!(transport_err, Error::Transport(_)));
assert_eq!(
transport_err.to_string(),
"transport error: Connection failed"
);
let auth_err = Error::auth("Invalid signature");
assert!(auth_err.is_auth_error());
let rate_limit_err = Error::rate_limit("test", 30, Some(30000));
assert!(rate_limit_err.is_rate_limited());
assert!(rate_limit_err.is_retryable());
let http_err = Error::http(500, "Internal server error");
assert!(http_err.is_retryable());
}
#[rstest]
fn test_error_display() {
let err = Error::RateLimit {
scope: "info",
weight: 20,
retry_after_ms: Some(60000),
};
assert_eq!(
err.to_string(),
"Rate limited on info (weight=20) retry_after_ms=Some(60000)"
);
let err = Error::NonceWindow("Nonce too old".to_string());
assert_eq!(err.to_string(), "nonce window error: Nonce too old");
}
#[rstest]
fn test_retryable_errors() {
assert!(Error::transport("test").is_retryable());
assert!(Error::Timeout.is_retryable());
assert!(Error::rate_limit("test", 10, None).is_retryable());
assert!(Error::http(500, "server error").is_retryable());
assert!(!Error::auth("test").is_retryable());
assert!(!Error::bad_request("test").is_retryable());
assert!(!Error::decode("test").is_retryable());
}
}