use std::time::Duration;
use http::header::{HeaderMap, RETRY_AFTER};
use http::StatusCode;
use polyoxide_venue::{class_for_status, parse_retry_after, Class, Classify};
use thiserror::Error;
use crate::hooks::Refused;
use crate::transport::TransportError;
#[derive(Clone)]
#[non_exhaustive]
pub struct ErrorResponse {
pub status: StatusCode,
pub headers: HeaderMap,
pub body: String,
pub message: String,
pub retry_after: Option<Duration>,
}
impl ErrorResponse {
pub fn new(status: StatusCode, headers: HeaderMap, body: impl Into<String>) -> Self {
let body = body.into();
let message = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| {
v.get("error")
.or(v.get("message"))
.and_then(|m| m.as_str())
.map(String::from)
})
.unwrap_or_else(|| body.clone());
let retry_after = headers
.get(RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(|value| parse_retry_after(value, Duration::MAX));
Self {
status,
headers,
body,
message,
retry_after,
}
}
pub async fn read(response: crate::Response) -> Self {
let status = response.status();
let headers = response.headers().clone();
let body = response.text().await.unwrap_or_default();
tracing::debug!("API error response body: {}", body);
Self::new(status, headers, body)
}
}
impl std::fmt::Debug for ErrorResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let header_names: Vec<&str> = self.headers.keys().map(|name| name.as_str()).collect();
f.debug_struct("ErrorResponse")
.field("status", &self.status)
.field("headers", &header_names)
.field("message", &crate::truncate_for_log(&self.message))
.field("body", &crate::truncate_for_log(&self.body))
.field("retry_after", &self.retry_after)
.finish()
}
}
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum ApiError {
#[error("API error: {} - {}", .0.status.as_u16(), .0.message)]
Response(Box<ErrorResponse>),
#[error("Validation error: {0}")]
Validation(String),
#[error("Transport error: {0}")]
Transport(#[from] TransportError),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("URL error: {0}")]
Url(#[from] url::ParseError),
#[error("Refused before sending: {0}")]
Refused(#[from] Refused),
#[error("Signing failed: {0}")]
Sign(Box<dyn std::error::Error + Send + Sync>),
}
impl ApiError {
pub async fn from_response(response: crate::Response) -> Self {
ErrorResponse::read(response).await.into()
}
}
impl From<ErrorResponse> for ApiError {
fn from(response: ErrorResponse) -> Self {
Self::Response(Box::new(response))
}
}
fn status_class(status: u16) -> Class {
class_for_status(status).unwrap_or(Class::Decode)
}
impl Classify for ApiError {
fn class(&self) -> Class {
match self {
Self::Response(response) => {
status_class(response.status.as_u16()).with_retry_after(response.retry_after)
}
Self::Validation(_) => Class::InvalidRequest,
Self::Transport(err) => err.class(),
Self::Serialization(_) => Class::Decode,
Self::Url(_) => Class::InvalidRequest,
Self::Refused(refused) => refused.class(),
Self::Sign(_) => Class::InvalidRequest,
}
}
fn is_fault(&self) -> bool {
!matches!(self, Self::Response(response)
if response.status == StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS)
}
fn retry_after(&self) -> Option<Duration> {
match self {
Self::Response(response) => response.retry_after,
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::TransportKind;
fn response(status: u16, body: &str, retry_after: Option<&str>) -> ErrorResponse {
let mut headers = HeaderMap::new();
if let Some(value) = retry_after {
headers.insert(RETRY_AFTER, value.parse().unwrap());
}
ErrorResponse::new(StatusCode::from_u16(status).unwrap(), headers, body)
}
fn api(status: u16) -> ApiError {
response(status, "", None).into()
}
fn transport(
kind: TransportKind,
source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
) -> ApiError {
ApiError::Transport(TransportError::new(kind, source))
}
#[test]
fn test_rate_limit_error_carries_message() {
let err = ApiError::from(response(
429,
r#"{"error":"too many requests, retry after 5s"}"#,
None,
));
let display = format!("{}", err);
assert!(
display.contains("too many requests"),
"A 429's display should contain the message: {}",
display
);
}
#[test]
fn test_rate_limit_error_display_format() {
let err = ApiError::from(response(429, r#"{"error":"slow down"}"#, None));
assert_eq!(format!("{}", err), "API error: 429 - slow down");
}
#[test]
fn test_error_response_reads_error_then_message() {
let bad = response(400, r#"{"error":"bad limit"}"#, None);
assert_eq!(bad.message, "bad limit");
assert_eq!(bad.body, r#"{"error":"bad limit"}"#);
assert_eq!(response(503, r#"{"message":"down"}"#, None).message, "down");
assert_eq!(
response(503, r#"{"error":"first","message":"second"}"#, None).message,
"first"
);
}
#[test]
fn test_error_response_keeps_a_non_json_body_verbatim() {
let blocked = response(429, "error code: 1015", None);
assert_eq!(blocked.message, "error code: 1015");
assert_eq!(blocked.status, StatusCode::TOO_MANY_REQUESTS);
}
#[test]
fn test_error_response_reads_retry_after_unclamped() {
let at = |value| response(429, "", Some(value)).retry_after;
assert_eq!(at("7"), Some(Duration::from_secs(7)));
assert_eq!(at("1.5"), Some(Duration::from_millis(1500)));
assert_eq!(at("604800"), Some(Duration::from_secs(604_800)));
for junk in ["0", "0.0", "-1", "soon", "Wed, 21 Oct 2026 07:28:00 GMT"] {
assert_eq!(at(junk), None, "{junk:?}");
}
assert_eq!(response(429, "", None).retry_after, None);
}
#[test]
fn test_error_response_debug_is_bounded_and_names_headers_only() {
let mut headers = HeaderMap::new();
headers.insert("authorization", "Bearer s3cr3t-t0ken".parse().unwrap());
headers.insert("set-cookie", "session=c00kie-value".parse().unwrap());
let body = "x".repeat(10_000);
let response = ErrorResponse::new(StatusCode::BAD_GATEWAY, headers, body);
let debug = format!("{:?}", ApiError::from(response));
assert!(
debug.len() < 1_500,
"{} bytes of Debug for a 10 kB body",
debug.len()
);
assert!(debug.contains("authorization") && debug.contains("set-cookie"));
assert!(
!debug.contains("s3cr3t-t0ken") && !debug.contains("c00kie-value"),
"{debug}"
);
assert!(debug.starts_with("Response(ErrorResponse { status: 502,"));
}
#[test]
fn test_response_display_is_the_status_and_message() {
let err = ApiError::from(response(503, r#"{"error":"bad gateway"}"#, None));
assert_eq!(err.to_string(), "API error: 503 - bad gateway");
assert_eq!(
ApiError::Validation("bad request".into()).to_string(),
"Validation error: bad request"
);
}
#[test]
fn test_retriable_transient_failures() {
assert!(Classify::is_retriable(&api(429)));
assert!(Classify::is_retriable(&api(408)));
for status in [500u16, 502, 503, 504] {
assert!(
Classify::is_retriable(&api(status)),
"{status} should be retriable"
);
}
}
#[test]
fn test_retriable_425_too_early() {
assert!(Classify::is_retriable(&api(425)));
}
#[test]
fn test_not_retriable_deterministic_failures() {
assert!(!Classify::is_retriable(&ApiError::Validation(
"bad payload".into()
)));
assert!(!Classify::is_retriable(&api(400)));
assert!(!Classify::is_retriable(&api(401)));
for status in [404u16, 409, 418] {
assert!(
!Classify::is_retriable(&api(status)),
"{status} should not be retriable"
);
}
}
#[test]
fn test_not_retriable_local_encode_decode_failures() {
let json_err = serde_json::from_str::<String>("not json").unwrap_err();
assert!(!Classify::is_retriable(&ApiError::Serialization(json_err)));
let url_err = url::Url::parse("://bad").unwrap_err();
assert!(!Classify::is_retriable(&ApiError::Url(url_err)));
}
struct ReqwestErrors {
builder: &'static str,
cut_off: &'static str,
connect: &'static str,
timeout: &'static str,
}
fn reqwest_errors() -> ReqwestErrors {
ReqwestErrors {
builder: "not a url",
cut_off: "connection closed before the body ended",
connect: "connection refused",
timeout: "operation timed out",
}
}
#[test]
fn every_variant_classifies() {
let reqwest = reqwest_errors();
let json = serde_json::from_str::<String>("not json").unwrap_err();
let url = url::Url::parse("://bad").unwrap_err();
let unavailable = Class::Unavailable { code: None };
let refusal = Class::VenueRefusal { code: None };
let secs = |n| Some(Duration::from_secs(n));
let rows = [
(api(200), Class::Decode, true, None),
(api(302), Class::Decode, true, None),
(api(400), refusal.clone(), true, None),
(api(401), Class::Unauthorized, true, None),
(api(403), Class::Unauthorized, true, None),
(api(404), refusal.clone(), true, None),
(api(408), unavailable.clone(), true, None),
(api(418), Class::Restricted, true, None),
(api(425), unavailable.clone(), true, None),
(
api(429),
Class::RateLimited { retry_after: None },
true,
None,
),
(api(451), Class::Restricted, false, None),
(api(500), unavailable.clone(), true, None),
(api(503), unavailable.clone(), true, None),
(api(600), Class::Decode, true, None),
(
response(403, r#"{"error":"no"}"#, None).into(),
Class::Unauthorized,
true,
None,
),
(
ApiError::Validation("bad".into()),
Class::InvalidRequest,
true,
None,
),
(
response(400, r#"{"error":"bad"}"#, None).into(),
refusal.clone(),
true,
None,
),
(
response(429, "slow", Some("7")).into(),
Class::RateLimited {
retry_after: secs(7),
},
true,
secs(7),
),
(
response(429, "slow", Some("0")).into(),
Class::RateLimited { retry_after: None },
true,
None,
),
(
response(503, "down", Some("5")).into(),
unavailable.clone(),
true,
secs(5),
),
(
response(408, "slow", None).into(),
unavailable.clone(),
true,
None,
),
(
transport(TransportKind::Request, reqwest.builder),
Class::InvalidRequest,
true,
None,
),
(
transport(TransportKind::Body, reqwest.cut_off),
Class::Network,
true,
None,
),
(
transport(TransportKind::Connect, reqwest.connect),
Class::Network,
true,
None,
),
(
transport(TransportKind::Timeout, reqwest.timeout),
Class::Network,
true,
None,
),
(
transport(TransportKind::Other, "no answer"),
Class::Network,
true,
None,
),
(ApiError::Serialization(json), Class::Decode, true, None),
(ApiError::Url(url), Class::InvalidRequest, true, None),
(
ApiError::Refused(Refused {
layer: crate::LayerId("order"),
units: 2_000,
capacity: 120,
}),
Class::InvalidRequest,
true,
None,
),
(
ApiError::Sign("no key to sign with".into()),
Class::InvalidRequest,
true,
None,
),
];
for (err, class, fault, wait) in rows {
assert_eq!(err.class(), class, "{err:?}");
assert_eq!(err.is_fault(), fault, "{err:?}");
assert_eq!(Classify::retry_after(&err), wait, "{err:?}");
assert_eq!(
Classify::is_retriable(&err),
class.is_retriable(),
"{err:?}"
);
}
}
}