use std::time::Duration;
use polyoxide_venue::{class_for_status, parse_retry_after, Class, Classify};
use reqwest::header::{HeaderMap, RETRY_AFTER};
use reqwest::StatusCode;
use thiserror::Error;
use crate::hooks::Refused;
#[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: reqwest::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("Network error: {0}")]
Network(#[from] reqwest::Error),
#[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: reqwest::Response) -> Self {
ErrorResponse::read(response).await.into()
}
}
impl From<ErrorResponse> for ApiError {
fn from(response: ErrorResponse) -> Self {
Self::Response(Box::new(response))
}
}
pub fn classify_reqwest(err: &reqwest::Error) -> Class {
if failed_in_transit(err) {
Class::Network
} else if let Some(status) = err.status() {
status_class(status.as_u16())
} else if err.is_builder() {
Class::InvalidRequest
} else if err.is_decode() {
Class::Decode
} else {
Class::Network
}
}
fn failed_in_transit(err: &reqwest::Error) -> bool {
if err.is_timeout() || err.is_connect() || err.is_body() {
return true;
}
let mut source = std::error::Error::source(err);
while let Some(inner) = source {
if inner.is::<std::io::Error>()
|| inner
.downcast_ref::<reqwest::Error>()
.is_some_and(reqwest::Error::is_body)
{
return true;
}
source = inner.source();
}
false
}
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::Network(err) => classify_reqwest(err),
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::*;
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()
}
#[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: reqwest::Error,
decode: reqwest::Error,
cut_off: reqwest::Error,
connect: reqwest::Error,
timeout: reqwest::Error,
unavailable: reqwest::Error,
not_found: reqwest::Error,
}
async fn reqwest_errors() -> ReqwestErrors {
use std::io::{Read, Write};
let builder = reqwest::Client::new().get("not a url").build().unwrap_err();
assert!(builder.is_builder());
let client = reqwest::Client::builder().no_proxy().build().unwrap();
let mut server = mockito::Server::new_async().await;
let _not_json = server
.mock("GET", "/not-json")
.with_body("not json")
.create_async()
.await;
let _down = server
.mock("GET", "/down")
.with_status(503)
.create_async()
.await;
let _missing = server
.mock("GET", "/missing")
.with_status(404)
.create_async()
.await;
let get = |path: &str| client.get(format!("{}{path}", server.url())).send();
let decode = get("/not-json")
.await
.unwrap()
.json::<serde_json::Value>()
.await
.unwrap_err();
assert!(decode.is_decode());
let status = |response: reqwest::Response| response.error_for_status().unwrap_err();
let unavailable = status(get("/down").await.unwrap());
let not_found = status(get("/missing").await.unwrap());
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let cut_off_url = format!("http://{}", listener.local_addr().unwrap());
let server_thread = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let _ = stream.read(&mut [0; 1024]);
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n{\"a\":");
});
let cut_off = client
.get(&cut_off_url)
.send()
.await
.unwrap()
.json::<serde_json::Value>()
.await
.unwrap_err();
server_thread.join().unwrap();
assert!(cut_off.is_decode(), "{cut_off:?}");
let port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
let connect = client
.get(format!("http://127.0.0.1:{port}"))
.send()
.await
.unwrap_err();
assert!(connect.is_connect());
let silent = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let timeout = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_millis(300))
.build()
.unwrap()
.get(format!("http://{}", silent.local_addr().unwrap()))
.send()
.await
.unwrap_err();
assert!(timeout.is_timeout());
ReqwestErrors {
builder,
decode,
cut_off,
connect,
timeout,
unavailable,
not_found,
}
}
#[tokio::test]
async fn every_variant_classifies() {
let reqwest = reqwest_errors().await;
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,
),
(
ApiError::Network(reqwest.builder),
Class::InvalidRequest,
true,
None,
),
(ApiError::Network(reqwest.decode), Class::Decode, true, None),
(
ApiError::Network(reqwest.cut_off),
Class::Network,
true,
None,
),
(
ApiError::Network(reqwest.connect),
Class::Network,
true,
None,
),
(
ApiError::Network(reqwest.timeout),
Class::Network,
true,
None,
),
(
ApiError::Network(reqwest.unavailable),
unavailable,
true,
None,
),
(ApiError::Network(reqwest.not_found), refusal, 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:?}"
);
}
}
}