1use std::time::Duration;
2
3use polyoxide_venue::{class_for_status, parse_retry_after, Class, Classify};
4use reqwest::header::{HeaderMap, RETRY_AFTER};
5use reqwest::StatusCode;
6use thiserror::Error;
7
8use crate::hooks::Refused;
9
10#[derive(Clone)]
18#[non_exhaustive]
19pub struct ErrorResponse {
20 pub status: StatusCode,
22 pub headers: HeaderMap,
24 pub body: String,
26 pub message: String,
29 pub retry_after: Option<Duration>,
33}
34
35impl ErrorResponse {
36 pub fn new(status: StatusCode, headers: HeaderMap, body: impl Into<String>) -> Self {
39 let body = body.into();
40 let message = serde_json::from_str::<serde_json::Value>(&body)
41 .ok()
42 .and_then(|v| {
43 v.get("error")
44 .or(v.get("message"))
45 .and_then(|m| m.as_str())
46 .map(String::from)
47 })
48 .unwrap_or_else(|| body.clone());
49 let retry_after = headers
50 .get(RETRY_AFTER)
51 .and_then(|value| value.to_str().ok())
52 .and_then(|value| parse_retry_after(value, Duration::MAX));
53 Self {
54 status,
55 headers,
56 body,
57 message,
58 retry_after,
59 }
60 }
61
62 pub async fn read(response: reqwest::Response) -> Self {
64 let status = response.status();
65 let headers = response.headers().clone();
66 let body = response.text().await.unwrap_or_default();
67 tracing::debug!("API error response body: {}", body);
68 Self::new(status, headers, body)
69 }
70}
71
72impl std::fmt::Debug for ErrorResponse {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 let header_names: Vec<&str> = self.headers.keys().map(|name| name.as_str()).collect();
77 f.debug_struct("ErrorResponse")
78 .field("status", &self.status)
79 .field("headers", &header_names)
80 .field("message", &crate::truncate_for_log(&self.message))
81 .field("body", &crate::truncate_for_log(&self.body))
82 .field("retry_after", &self.retry_after)
83 .finish()
84 }
85}
86
87#[derive(Error, Debug)]
89#[non_exhaustive]
90pub enum ApiError {
91 #[error("API error: {} - {}", .0.status.as_u16(), .0.message)]
94 Response(Box<ErrorResponse>),
95
96 #[error("Validation error: {0}")]
99 Validation(String),
100
101 #[error("Network error: {0}")]
103 Network(#[from] reqwest::Error),
104
105 #[error("Serialization error: {0}")]
107 Serialization(#[from] serde_json::Error),
108
109 #[error("URL error: {0}")]
111 Url(#[from] url::ParseError),
112
113 #[error("Refused before sending: {0}")]
116 Refused(#[from] Refused),
117
118 #[error("Signing failed: {0}")]
122 Sign(Box<dyn std::error::Error + Send + Sync>),
123}
124
125impl ApiError {
126 pub async fn from_response(response: reqwest::Response) -> Self {
128 ErrorResponse::read(response).await.into()
129 }
130}
131
132impl From<ErrorResponse> for ApiError {
133 fn from(response: ErrorResponse) -> Self {
134 Self::Response(Box::new(response))
135 }
136}
137
138pub fn classify_reqwest(err: &reqwest::Error) -> Class {
148 if failed_in_transit(err) {
149 Class::Network
150 } else if let Some(status) = err.status() {
151 status_class(status.as_u16())
152 } else if err.is_builder() {
153 Class::InvalidRequest
154 } else if err.is_decode() {
155 Class::Decode
156 } else {
157 Class::Network
158 }
159}
160
161fn failed_in_transit(err: &reqwest::Error) -> bool {
163 if err.is_timeout() || err.is_connect() || err.is_body() {
164 return true;
165 }
166 let mut source = std::error::Error::source(err);
167 while let Some(inner) = source {
168 if inner.is::<std::io::Error>()
169 || inner
170 .downcast_ref::<reqwest::Error>()
171 .is_some_and(reqwest::Error::is_body)
172 {
173 return true;
174 }
175 source = inner.source();
176 }
177 false
178}
179
180fn status_class(status: u16) -> Class {
182 class_for_status(status).unwrap_or(Class::Decode)
183}
184
185impl Classify for ApiError {
198 fn class(&self) -> Class {
199 match self {
200 Self::Response(response) => {
201 status_class(response.status.as_u16()).with_retry_after(response.retry_after)
202 }
203 Self::Validation(_) => Class::InvalidRequest,
204 Self::Network(err) => classify_reqwest(err),
205 Self::Serialization(_) => Class::Decode,
206 Self::Url(_) => Class::InvalidRequest,
207 Self::Refused(refused) => refused.class(),
208 Self::Sign(_) => Class::InvalidRequest,
209 }
210 }
211
212 fn is_fault(&self) -> bool {
213 !matches!(self, Self::Response(response)
214 if response.status == StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS)
215 }
216
217 fn retry_after(&self) -> Option<Duration> {
218 match self {
219 Self::Response(response) => response.retry_after,
220 _ => None,
221 }
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 fn response(status: u16, body: &str, retry_after: Option<&str>) -> ErrorResponse {
231 let mut headers = HeaderMap::new();
232 if let Some(value) = retry_after {
233 headers.insert(RETRY_AFTER, value.parse().unwrap());
234 }
235 ErrorResponse::new(StatusCode::from_u16(status).unwrap(), headers, body)
236 }
237
238 fn api(status: u16) -> ApiError {
239 response(status, "", None).into()
240 }
241
242 #[test]
243 fn test_rate_limit_error_carries_message() {
244 let err = ApiError::from(response(
245 429,
246 r#"{"error":"too many requests, retry after 5s"}"#,
247 None,
248 ));
249 let display = format!("{}", err);
250 assert!(
251 display.contains("too many requests"),
252 "A 429's display should contain the message: {}",
253 display
254 );
255 }
256
257 #[test]
258 fn test_rate_limit_error_display_format() {
259 let err = ApiError::from(response(429, r#"{"error":"slow down"}"#, None));
260 assert_eq!(format!("{}", err), "API error: 429 - slow down");
261 }
262
263 #[test]
266 fn test_error_response_reads_error_then_message() {
267 let bad = response(400, r#"{"error":"bad limit"}"#, None);
268 assert_eq!(bad.message, "bad limit");
269 assert_eq!(bad.body, r#"{"error":"bad limit"}"#);
270 assert_eq!(response(503, r#"{"message":"down"}"#, None).message, "down");
271 assert_eq!(
272 response(503, r#"{"error":"first","message":"second"}"#, None).message,
273 "first"
274 );
275 }
276
277 #[test]
278 fn test_error_response_keeps_a_non_json_body_verbatim() {
279 let blocked = response(429, "error code: 1015", None);
281 assert_eq!(blocked.message, "error code: 1015");
282 assert_eq!(blocked.status, StatusCode::TOO_MANY_REQUESTS);
283 }
284
285 #[test]
286 fn test_error_response_reads_retry_after_unclamped() {
287 let at = |value| response(429, "", Some(value)).retry_after;
288 assert_eq!(at("7"), Some(Duration::from_secs(7)));
289 assert_eq!(at("1.5"), Some(Duration::from_millis(1500)));
290 assert_eq!(at("604800"), Some(Duration::from_secs(604_800)));
292 for junk in ["0", "0.0", "-1", "soon", "Wed, 21 Oct 2026 07:28:00 GMT"] {
294 assert_eq!(at(junk), None, "{junk:?}");
295 }
296 assert_eq!(response(429, "", None).retry_after, None);
297 }
298
299 #[test]
300 fn test_error_response_debug_is_bounded_and_names_headers_only() {
301 let mut headers = HeaderMap::new();
302 headers.insert("authorization", "Bearer s3cr3t-t0ken".parse().unwrap());
303 headers.insert("set-cookie", "session=c00kie-value".parse().unwrap());
304 let body = "x".repeat(10_000);
306 let response = ErrorResponse::new(StatusCode::BAD_GATEWAY, headers, body);
307
308 let debug = format!("{:?}", ApiError::from(response));
309 assert!(
310 debug.len() < 1_500,
311 "{} bytes of Debug for a 10 kB body",
312 debug.len()
313 );
314 assert!(debug.contains("authorization") && debug.contains("set-cookie"));
315 assert!(
316 !debug.contains("s3cr3t-t0ken") && !debug.contains("c00kie-value"),
317 "{debug}"
318 );
319 assert!(debug.starts_with("Response(ErrorResponse { status: 502,"));
320 }
321
322 #[test]
323 fn test_response_display_is_the_status_and_message() {
324 let err = ApiError::from(response(503, r#"{"error":"bad gateway"}"#, None));
325 assert_eq!(err.to_string(), "API error: 503 - bad gateway");
326 assert_eq!(
327 ApiError::Validation("bad request".into()).to_string(),
328 "Validation error: bad request"
329 );
330 }
331
332 #[test]
335 fn test_retriable_transient_failures() {
336 assert!(Classify::is_retriable(&api(429)));
337 assert!(Classify::is_retriable(&api(408)));
338 for status in [500u16, 502, 503, 504] {
339 assert!(
340 Classify::is_retriable(&api(status)),
341 "{status} should be retriable"
342 );
343 }
344 }
345
346 #[test]
347 fn test_retriable_425_too_early() {
348 assert!(Classify::is_retriable(&api(425)));
351 }
352
353 #[test]
354 fn test_not_retriable_deterministic_failures() {
355 assert!(!Classify::is_retriable(&ApiError::Validation(
356 "bad payload".into()
357 )));
358 assert!(!Classify::is_retriable(&api(400)));
359 assert!(!Classify::is_retriable(&api(401)));
360 for status in [404u16, 409, 418] {
361 assert!(
362 !Classify::is_retriable(&api(status)),
363 "{status} should not be retriable"
364 );
365 }
366 }
367
368 #[test]
369 fn test_not_retriable_local_encode_decode_failures() {
370 let json_err = serde_json::from_str::<String>("not json").unwrap_err();
371 assert!(!Classify::is_retriable(&ApiError::Serialization(json_err)));
372 let url_err = url::Url::parse("://bad").unwrap_err();
373 assert!(!Classify::is_retriable(&ApiError::Url(url_err)));
374 }
375
376 struct ReqwestErrors {
380 builder: reqwest::Error,
381 decode: reqwest::Error,
382 cut_off: reqwest::Error,
383 connect: reqwest::Error,
384 timeout: reqwest::Error,
385 unavailable: reqwest::Error,
386 not_found: reqwest::Error,
387 }
388
389 async fn reqwest_errors() -> ReqwestErrors {
390 use std::io::{Read, Write};
391
392 let builder = reqwest::Client::new().get("not a url").build().unwrap_err();
393 assert!(builder.is_builder());
394
395 let client = reqwest::Client::builder().no_proxy().build().unwrap();
398 let mut server = mockito::Server::new_async().await;
399 let _not_json = server
400 .mock("GET", "/not-json")
401 .with_body("not json")
402 .create_async()
403 .await;
404 let _down = server
405 .mock("GET", "/down")
406 .with_status(503)
407 .create_async()
408 .await;
409 let _missing = server
410 .mock("GET", "/missing")
411 .with_status(404)
412 .create_async()
413 .await;
414 let get = |path: &str| client.get(format!("{}{path}", server.url())).send();
415
416 let decode = get("/not-json")
417 .await
418 .unwrap()
419 .json::<serde_json::Value>()
420 .await
421 .unwrap_err();
422 assert!(decode.is_decode());
423
424 let status = |response: reqwest::Response| response.error_for_status().unwrap_err();
425 let unavailable = status(get("/down").await.unwrap());
426 let not_found = status(get("/missing").await.unwrap());
427
428 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
431 let cut_off_url = format!("http://{}", listener.local_addr().unwrap());
432 let server_thread = std::thread::spawn(move || {
433 let (mut stream, _) = listener.accept().unwrap();
434 let _ = stream.read(&mut [0; 1024]);
435 let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n{\"a\":");
436 });
437 let cut_off = client
438 .get(&cut_off_url)
439 .send()
440 .await
441 .unwrap()
442 .json::<serde_json::Value>()
443 .await
444 .unwrap_err();
445 server_thread.join().unwrap();
446 assert!(cut_off.is_decode(), "{cut_off:?}");
447
448 let port = std::net::TcpListener::bind("127.0.0.1:0")
450 .unwrap()
451 .local_addr()
452 .unwrap()
453 .port();
454 let connect = client
455 .get(format!("http://127.0.0.1:{port}"))
456 .send()
457 .await
458 .unwrap_err();
459 assert!(connect.is_connect());
460
461 let silent = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
463 let timeout = reqwest::Client::builder()
464 .no_proxy()
465 .timeout(std::time::Duration::from_millis(300))
466 .build()
467 .unwrap()
468 .get(format!("http://{}", silent.local_addr().unwrap()))
469 .send()
470 .await
471 .unwrap_err();
472 assert!(timeout.is_timeout());
473
474 ReqwestErrors {
475 builder,
476 decode,
477 cut_off,
478 connect,
479 timeout,
480 unavailable,
481 not_found,
482 }
483 }
484
485 #[tokio::test]
486 async fn every_variant_classifies() {
487 let reqwest = reqwest_errors().await;
488 let json = serde_json::from_str::<String>("not json").unwrap_err();
489 let url = url::Url::parse("://bad").unwrap_err();
490 let unavailable = Class::Unavailable { code: None };
491 let refusal = Class::VenueRefusal { code: None };
492 let secs = |n| Some(Duration::from_secs(n));
493
494 let rows = [
497 (api(200), Class::Decode, true, None),
498 (api(302), Class::Decode, true, None),
499 (api(400), refusal.clone(), true, None),
500 (api(401), Class::Unauthorized, true, None),
501 (api(403), Class::Unauthorized, true, None),
502 (api(404), refusal.clone(), true, None),
503 (api(408), unavailable.clone(), true, None),
504 (api(418), Class::Restricted, true, None),
505 (api(425), unavailable.clone(), true, None),
506 (
507 api(429),
508 Class::RateLimited { retry_after: None },
509 true,
510 None,
511 ),
512 (api(451), Class::Restricted, false, None),
514 (api(500), unavailable.clone(), true, None),
515 (api(503), unavailable.clone(), true, None),
516 (api(600), Class::Decode, true, None),
517 (
519 response(403, r#"{"error":"no"}"#, None).into(),
520 Class::Unauthorized,
521 true,
522 None,
523 ),
524 (
527 ApiError::Validation("bad".into()),
528 Class::InvalidRequest,
529 true,
530 None,
531 ),
532 (
533 response(400, r#"{"error":"bad"}"#, None).into(),
534 refusal.clone(),
535 true,
536 None,
537 ),
538 (
540 response(429, "slow", Some("7")).into(),
541 Class::RateLimited {
542 retry_after: secs(7),
543 },
544 true,
545 secs(7),
546 ),
547 (
548 response(429, "slow", Some("0")).into(),
549 Class::RateLimited { retry_after: None },
550 true,
551 None,
552 ),
553 (
555 response(503, "down", Some("5")).into(),
556 unavailable.clone(),
557 true,
558 secs(5),
559 ),
560 (
562 response(408, "slow", None).into(),
563 unavailable.clone(),
564 true,
565 None,
566 ),
567 (
568 ApiError::Network(reqwest.builder),
569 Class::InvalidRequest,
570 true,
571 None,
572 ),
573 (ApiError::Network(reqwest.decode), Class::Decode, true, None),
574 (
576 ApiError::Network(reqwest.cut_off),
577 Class::Network,
578 true,
579 None,
580 ),
581 (
582 ApiError::Network(reqwest.connect),
583 Class::Network,
584 true,
585 None,
586 ),
587 (
588 ApiError::Network(reqwest.timeout),
589 Class::Network,
590 true,
591 None,
592 ),
593 (
594 ApiError::Network(reqwest.unavailable),
595 unavailable,
596 true,
597 None,
598 ),
599 (ApiError::Network(reqwest.not_found), refusal, true, None),
600 (ApiError::Serialization(json), Class::Decode, true, None),
601 (ApiError::Url(url), Class::InvalidRequest, true, None),
602 (
603 ApiError::Refused(Refused {
604 layer: crate::LayerId("order"),
605 units: 2_000,
606 capacity: 120,
607 }),
608 Class::InvalidRequest,
609 true,
610 None,
611 ),
612 (
613 ApiError::Sign("no key to sign with".into()),
614 Class::InvalidRequest,
615 true,
616 None,
617 ),
618 ];
619 for (err, class, fault, wait) in rows {
620 assert_eq!(err.class(), class, "{err:?}");
621 assert_eq!(err.is_fault(), fault, "{err:?}");
622 assert_eq!(Classify::retry_after(&err), wait, "{err:?}");
623 assert_eq!(
624 Classify::is_retriable(&err),
625 class.is_retriable(),
626 "{err:?}"
627 );
628 }
629 }
630}