1use std::time::Duration;
2
3use http::header::{HeaderMap, RETRY_AFTER};
4use http::StatusCode;
5use polyoxide_venue::{class_for_status, parse_retry_after, Class, Classify};
6use thiserror::Error;
7
8use crate::hooks::Refused;
9use crate::transport::TransportError;
10
11#[derive(Clone)]
19#[non_exhaustive]
20pub struct ErrorResponse {
21 pub status: StatusCode,
23 pub headers: HeaderMap,
25 pub body: String,
27 pub message: String,
30 pub retry_after: Option<Duration>,
34}
35
36impl ErrorResponse {
37 pub fn new(status: StatusCode, headers: HeaderMap, body: impl Into<String>) -> Self {
40 let body = body.into();
41 let message = serde_json::from_str::<serde_json::Value>(&body)
42 .ok()
43 .and_then(|v| {
44 v.get("error")
45 .or(v.get("message"))
46 .and_then(|m| m.as_str())
47 .map(String::from)
48 })
49 .unwrap_or_else(|| body.clone());
50 let retry_after = headers
51 .get(RETRY_AFTER)
52 .and_then(|value| value.to_str().ok())
53 .and_then(|value| parse_retry_after(value, Duration::MAX));
54 Self {
55 status,
56 headers,
57 body,
58 message,
59 retry_after,
60 }
61 }
62
63 pub async fn read(response: crate::Response) -> Self {
65 let status = response.status();
66 let headers = response.headers().clone();
67 let body = response.text().await.unwrap_or_default();
68 tracing::debug!("API error response body: {}", body);
69 Self::new(status, headers, body)
70 }
71}
72
73impl std::fmt::Debug for ErrorResponse {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 let header_names: Vec<&str> = self.headers.keys().map(|name| name.as_str()).collect();
78 f.debug_struct("ErrorResponse")
79 .field("status", &self.status)
80 .field("headers", &header_names)
81 .field("message", &crate::truncate_for_log(&self.message))
82 .field("body", &crate::truncate_for_log(&self.body))
83 .field("retry_after", &self.retry_after)
84 .finish()
85 }
86}
87
88#[derive(Error, Debug)]
90#[non_exhaustive]
91pub enum ApiError {
92 #[error("API error: {} - {}", .0.status.as_u16(), .0.message)]
95 Response(Box<ErrorResponse>),
96
97 #[error("Validation error: {0}")]
100 Validation(String),
101
102 #[error("Transport error: {0}")]
105 Transport(#[from] TransportError),
106
107 #[error("Serialization error: {0}")]
109 Serialization(#[from] serde_json::Error),
110
111 #[error("URL error: {0}")]
113 Url(#[from] url::ParseError),
114
115 #[error("Refused before sending: {0}")]
118 Refused(#[from] Refused),
119
120 #[error("Signing failed: {0}")]
124 Sign(Box<dyn std::error::Error + Send + Sync>),
125}
126
127impl ApiError {
128 pub async fn from_response(response: crate::Response) -> Self {
130 ErrorResponse::read(response).await.into()
131 }
132}
133
134impl From<ErrorResponse> for ApiError {
135 fn from(response: ErrorResponse) -> Self {
136 Self::Response(Box::new(response))
137 }
138}
139
140fn status_class(status: u16) -> Class {
142 class_for_status(status).unwrap_or(Class::Decode)
143}
144
145impl Classify for ApiError {
158 fn class(&self) -> Class {
159 match self {
160 Self::Response(response) => {
161 status_class(response.status.as_u16()).with_retry_after(response.retry_after)
162 }
163 Self::Validation(_) => Class::InvalidRequest,
164 Self::Transport(err) => err.class(),
165 Self::Serialization(_) => Class::Decode,
166 Self::Url(_) => Class::InvalidRequest,
167 Self::Refused(refused) => refused.class(),
168 Self::Sign(_) => Class::InvalidRequest,
169 }
170 }
171
172 fn is_fault(&self) -> bool {
173 !matches!(self, Self::Response(response)
174 if response.status == StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS)
175 }
176
177 fn retry_after(&self) -> Option<Duration> {
178 match self {
179 Self::Response(response) => response.retry_after,
180 _ => None,
181 }
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::transport::TransportKind;
189
190 fn response(status: u16, body: &str, retry_after: Option<&str>) -> ErrorResponse {
192 let mut headers = HeaderMap::new();
193 if let Some(value) = retry_after {
194 headers.insert(RETRY_AFTER, value.parse().unwrap());
195 }
196 ErrorResponse::new(StatusCode::from_u16(status).unwrap(), headers, body)
197 }
198
199 fn api(status: u16) -> ApiError {
200 response(status, "", None).into()
201 }
202
203 fn transport(
205 kind: TransportKind,
206 source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
207 ) -> ApiError {
208 ApiError::Transport(TransportError::new(kind, source))
209 }
210
211 #[test]
212 fn test_rate_limit_error_carries_message() {
213 let err = ApiError::from(response(
214 429,
215 r#"{"error":"too many requests, retry after 5s"}"#,
216 None,
217 ));
218 let display = format!("{}", err);
219 assert!(
220 display.contains("too many requests"),
221 "A 429's display should contain the message: {}",
222 display
223 );
224 }
225
226 #[test]
227 fn test_rate_limit_error_display_format() {
228 let err = ApiError::from(response(429, r#"{"error":"slow down"}"#, None));
229 assert_eq!(format!("{}", err), "API error: 429 - slow down");
230 }
231
232 #[test]
235 fn test_error_response_reads_error_then_message() {
236 let bad = response(400, r#"{"error":"bad limit"}"#, None);
237 assert_eq!(bad.message, "bad limit");
238 assert_eq!(bad.body, r#"{"error":"bad limit"}"#);
239 assert_eq!(response(503, r#"{"message":"down"}"#, None).message, "down");
240 assert_eq!(
241 response(503, r#"{"error":"first","message":"second"}"#, None).message,
242 "first"
243 );
244 }
245
246 #[test]
247 fn test_error_response_keeps_a_non_json_body_verbatim() {
248 let blocked = response(429, "error code: 1015", None);
250 assert_eq!(blocked.message, "error code: 1015");
251 assert_eq!(blocked.status, StatusCode::TOO_MANY_REQUESTS);
252 }
253
254 #[test]
255 fn test_error_response_reads_retry_after_unclamped() {
256 let at = |value| response(429, "", Some(value)).retry_after;
257 assert_eq!(at("7"), Some(Duration::from_secs(7)));
258 assert_eq!(at("1.5"), Some(Duration::from_millis(1500)));
259 assert_eq!(at("604800"), Some(Duration::from_secs(604_800)));
261 for junk in ["0", "0.0", "-1", "soon", "Wed, 21 Oct 2026 07:28:00 GMT"] {
263 assert_eq!(at(junk), None, "{junk:?}");
264 }
265 assert_eq!(response(429, "", None).retry_after, None);
266 }
267
268 #[test]
269 fn test_error_response_debug_is_bounded_and_names_headers_only() {
270 let mut headers = HeaderMap::new();
271 headers.insert("authorization", "Bearer s3cr3t-t0ken".parse().unwrap());
272 headers.insert("set-cookie", "session=c00kie-value".parse().unwrap());
273 let body = "x".repeat(10_000);
275 let response = ErrorResponse::new(StatusCode::BAD_GATEWAY, headers, body);
276
277 let debug = format!("{:?}", ApiError::from(response));
278 assert!(
279 debug.len() < 1_500,
280 "{} bytes of Debug for a 10 kB body",
281 debug.len()
282 );
283 assert!(debug.contains("authorization") && debug.contains("set-cookie"));
284 assert!(
285 !debug.contains("s3cr3t-t0ken") && !debug.contains("c00kie-value"),
286 "{debug}"
287 );
288 assert!(debug.starts_with("Response(ErrorResponse { status: 502,"));
289 }
290
291 #[test]
292 fn test_response_display_is_the_status_and_message() {
293 let err = ApiError::from(response(503, r#"{"error":"bad gateway"}"#, None));
294 assert_eq!(err.to_string(), "API error: 503 - bad gateway");
295 assert_eq!(
296 ApiError::Validation("bad request".into()).to_string(),
297 "Validation error: bad request"
298 );
299 }
300
301 #[test]
304 fn test_retriable_transient_failures() {
305 assert!(Classify::is_retriable(&api(429)));
306 assert!(Classify::is_retriable(&api(408)));
307 for status in [500u16, 502, 503, 504] {
308 assert!(
309 Classify::is_retriable(&api(status)),
310 "{status} should be retriable"
311 );
312 }
313 }
314
315 #[test]
316 fn test_retriable_425_too_early() {
317 assert!(Classify::is_retriable(&api(425)));
320 }
321
322 #[test]
323 fn test_not_retriable_deterministic_failures() {
324 assert!(!Classify::is_retriable(&ApiError::Validation(
325 "bad payload".into()
326 )));
327 assert!(!Classify::is_retriable(&api(400)));
328 assert!(!Classify::is_retriable(&api(401)));
329 for status in [404u16, 409, 418] {
330 assert!(
331 !Classify::is_retriable(&api(status)),
332 "{status} should not be retriable"
333 );
334 }
335 }
336
337 #[test]
338 fn test_not_retriable_local_encode_decode_failures() {
339 let json_err = serde_json::from_str::<String>("not json").unwrap_err();
340 assert!(!Classify::is_retriable(&ApiError::Serialization(json_err)));
341 let url_err = url::Url::parse("://bad").unwrap_err();
342 assert!(!Classify::is_retriable(&ApiError::Url(url_err)));
343 }
344
345 struct ReqwestErrors {
351 builder: &'static str,
352 cut_off: &'static str,
353 connect: &'static str,
354 timeout: &'static str,
355 }
356
357 fn reqwest_errors() -> ReqwestErrors {
358 ReqwestErrors {
359 builder: "not a url",
360 cut_off: "connection closed before the body ended",
361 connect: "connection refused",
362 timeout: "operation timed out",
363 }
364 }
365
366 #[test]
367 fn every_variant_classifies() {
368 let reqwest = reqwest_errors();
369 let json = serde_json::from_str::<String>("not json").unwrap_err();
370 let url = url::Url::parse("://bad").unwrap_err();
371 let unavailable = Class::Unavailable { code: None };
372 let refusal = Class::VenueRefusal { code: None };
373 let secs = |n| Some(Duration::from_secs(n));
374
375 let rows = [
378 (api(200), Class::Decode, true, None),
379 (api(302), Class::Decode, true, None),
380 (api(400), refusal.clone(), true, None),
381 (api(401), Class::Unauthorized, true, None),
382 (api(403), Class::Unauthorized, true, None),
383 (api(404), refusal.clone(), true, None),
384 (api(408), unavailable.clone(), true, None),
385 (api(418), Class::Restricted, true, None),
386 (api(425), unavailable.clone(), true, None),
387 (
388 api(429),
389 Class::RateLimited { retry_after: None },
390 true,
391 None,
392 ),
393 (api(451), Class::Restricted, false, None),
395 (api(500), unavailable.clone(), true, None),
396 (api(503), unavailable.clone(), true, None),
397 (api(600), Class::Decode, true, None),
398 (
400 response(403, r#"{"error":"no"}"#, None).into(),
401 Class::Unauthorized,
402 true,
403 None,
404 ),
405 (
408 ApiError::Validation("bad".into()),
409 Class::InvalidRequest,
410 true,
411 None,
412 ),
413 (
414 response(400, r#"{"error":"bad"}"#, None).into(),
415 refusal.clone(),
416 true,
417 None,
418 ),
419 (
421 response(429, "slow", Some("7")).into(),
422 Class::RateLimited {
423 retry_after: secs(7),
424 },
425 true,
426 secs(7),
427 ),
428 (
429 response(429, "slow", Some("0")).into(),
430 Class::RateLimited { retry_after: None },
431 true,
432 None,
433 ),
434 (
436 response(503, "down", Some("5")).into(),
437 unavailable.clone(),
438 true,
439 secs(5),
440 ),
441 (
443 response(408, "slow", None).into(),
444 unavailable.clone(),
445 true,
446 None,
447 ),
448 (
449 transport(TransportKind::Request, reqwest.builder),
450 Class::InvalidRequest,
451 true,
452 None,
453 ),
454 (
457 transport(TransportKind::Body, reqwest.cut_off),
458 Class::Network,
459 true,
460 None,
461 ),
462 (
463 transport(TransportKind::Connect, reqwest.connect),
464 Class::Network,
465 true,
466 None,
467 ),
468 (
469 transport(TransportKind::Timeout, reqwest.timeout),
470 Class::Network,
471 true,
472 None,
473 ),
474 (
475 transport(TransportKind::Other, "no answer"),
476 Class::Network,
477 true,
478 None,
479 ),
480 (ApiError::Serialization(json), Class::Decode, true, None),
481 (ApiError::Url(url), Class::InvalidRequest, true, None),
482 (
483 ApiError::Refused(Refused {
484 layer: crate::LayerId("order"),
485 units: 2_000,
486 capacity: 120,
487 }),
488 Class::InvalidRequest,
489 true,
490 None,
491 ),
492 (
493 ApiError::Sign("no key to sign with".into()),
494 Class::InvalidRequest,
495 true,
496 None,
497 ),
498 ];
499 for (err, class, fault, wait) in rows {
500 assert_eq!(err.class(), class, "{err:?}");
501 assert_eq!(err.is_fault(), fault, "{err:?}");
502 assert_eq!(Classify::retry_after(&err), wait, "{err:?}");
503 assert_eq!(
504 Classify::is_retriable(&err),
505 class.is_retriable(),
506 "{err:?}"
507 );
508 }
509 }
510}