use nautilus_network::http::{HttpClientError, ReqwestError};
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 (retry_after_ms={retry_after_ms:?})")]
RateLimit { retry_after_ms: Option<u64> },
#[error("bad request: {0}")]
BadRequest(String),
#[error("exchange error: {0}")]
Exchange(String),
#[error("timeout")]
Timeout,
#[error("decode error: {0}")]
Decode(String),
#[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(retry_after_ms: Option<u64>) -> Self {
Self::RateLimit { retry_after_ms }
}
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: u16, body: &[u8]) -> Self {
let message = String::from_utf8_lossy(body).to_string();
match status {
401 | 403 => Self::auth(format!("HTTP {status}: {message}")),
400 => Self::bad_request(format!("HTTP {status}: {message}")),
429 => Self::rate_limit(None),
500..=599 => Self::exchange(format!("HTTP {status}: {message}")),
_ => Self::http(status, message),
}
}
#[expect(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(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}"))
}
}
#[expect(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 { .. } | Self::Exchange(_) => 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 JWT");
assert!(auth_err.is_auth_error());
let rate_limit_err = Error::rate_limit(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_retryable_errors() {
assert!(Error::transport("test").is_retryable());
assert!(Error::Timeout.is_retryable());
assert!(Error::rate_limit(None).is_retryable());
assert!(Error::http(500, "server error").is_retryable());
assert!(Error::exchange("server error").is_retryable());
assert!(!Error::auth("test").is_retryable());
assert!(!Error::bad_request("test").is_retryable());
assert!(!Error::decode("test").is_retryable());
}
#[rstest]
#[case(401, true, false, false)]
#[case(403, true, false, false)]
#[case(400, false, false, false)]
#[case(429, false, true, true)]
#[case(500, false, false, true)]
#[case(503, false, false, true)]
#[case(404, false, false, false)]
fn test_from_http_status_classification(
#[case] status: u16,
#[case] expect_auth: bool,
#[case] expect_rate_limit: bool,
#[case] expect_retryable: bool,
) {
let err = Error::from_http_status(status, b"test body");
assert_eq!(err.is_auth_error(), expect_auth, "is_auth for {status}");
assert_eq!(
err.is_rate_limited(),
expect_rate_limit,
"is_rate_limited for {status}"
);
assert_eq!(
err.is_retryable(),
expect_retryable,
"is_retryable for {status}"
);
}
#[rstest]
fn test_error_display() {
let err = Error::RateLimit {
retry_after_ms: Some(60000),
};
assert_eq!(err.to_string(), "Rate limited (retry_after_ms=Some(60000))");
}
}