Skip to main content

polyoxide_core/
error.rs

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/// An unsuccessful response, read whole: what [`ApiError::Response`] carries.
11///
12/// The send loop builds one when the client's policy fails a response, and a
13/// venue's `From<ApiError>` decodes its own body shape from [`body`](Self::body).
14///
15/// Its `Debug`, which the `Request failed` ERROR line prints, names the
16/// headers without their values and truncates the message and the body.
17#[derive(Clone)]
18#[non_exhaustive]
19pub struct ErrorResponse {
20    /// The response's status.
21    pub status: StatusCode,
22    /// The response's headers.
23    pub headers: HeaderMap,
24    /// The response's body, verbatim, or `""` when it could not be read.
25    pub body: String,
26    /// The body's `error` field, else its `message` field, else the body
27    /// verbatim: the shape most venue hosts answer with.
28    pub message: String,
29    /// The response's `Retry-After`, read by
30    /// [`polyoxide_venue::parse_retry_after`] with no clamp, since it is
31    /// surfaced, never slept on. A zero is `None`.
32    pub retry_after: Option<Duration>,
33}
34
35impl ErrorResponse {
36    /// An unsuccessful response from its parts, its message and `Retry-After`
37    /// read from them.
38    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    /// Read `response` whole. A body that cannot be read is `""`.
63    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
72/// Written by hand so a log line never carries a header's value (a token, a
73/// cookie) or an unbounded body.
74impl 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/// Core API error types shared across Polyoxide clients
88#[derive(Error, Debug)]
89#[non_exhaustive]
90pub enum ApiError {
91    /// The server answered with an unsuccessful status. Its class is the
92    /// status's.
93    #[error("API error: {} - {}", .0.status.as_u16(), .0.message)]
94    Response(Box<ErrorResponse>),
95
96    /// The client refused the request before sending it: bad input, or
97    /// missing configuration.
98    #[error("Validation error: {0}")]
99    Validation(String),
100
101    /// Network error
102    #[error("Network error: {0}")]
103    Network(#[from] reqwest::Error),
104
105    /// JSON serialization/deserialization error
106    #[error("Serialization error: {0}")]
107    Serialization(#[from] serde_json::Error),
108
109    /// URL parsing error
110    #[error("URL error: {0}")]
111    Url(#[from] url::ParseError),
112
113    /// The throttle refused a cost no layer can ever hold, and nothing was
114    /// sent.
115    #[error("Refused before sending: {0}")]
116    Refused(#[from] Refused),
117
118    /// An [`Authenticator`](crate::Authenticator) could not sign the request,
119    /// and nothing was sent. Carries the venue's own error, which the venue's
120    /// `From<ApiError>` takes back out.
121    #[error("Signing failed: {0}")]
122    Sign(Box<dyn std::error::Error + Send + Sync>),
123}
124
125impl ApiError {
126    /// Read an unsuccessful response whole, as [`ApiError::Response`].
127    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
138/// The class of a transport error from `reqwest`.
139///
140/// A failure in transit is [`Class::Network`]: a timeout, a failed connect, or
141/// any error with an I/O error or a body-read failure in its source chain.
142/// reqwest reports a body that broke off mid-read as a decode error, so these
143/// are checked first. An error that carries an HTTP status follows the status
144/// rule. Then a request that could not be built is [`Class::InvalidRequest`],
145/// a body that arrived whole but did not decode is [`Class::Decode`], and
146/// anything else is [`Class::Network`].
147pub 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
161/// Whether a reqwest error is the connection failing, however reqwest labels it.
162fn 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
180/// The status rule, with any status outside 400–599 a [`Class::Decode`].
181fn status_class(status: u16) -> Class {
182    class_for_status(status).unwrap_or(Class::Decode)
183}
184
185/// The class each variant reports.
186///
187/// [`ApiError::Response`] follows the status rule, with any status outside
188/// 400–599 a [`Class::Decode`] (a 2xx that a caller found wanting, such as a
189/// ping whose body says it is not ok), and a `429`'s wait its `Retry-After`.
190/// A 451 is not a fault: the venue does not serve the caller's region, as
191/// designed. [`ApiError::Validation`] and [`ApiError::Sign`] are a
192/// [`Class::InvalidRequest`]: the client refused the request, or could not
193/// sign it, and nothing was sent.
194///
195/// [`Classify::retry_after`] is a response's `Retry-After` whatever its
196/// status, and `None` for every other variant.
197impl 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    /// A response with `status`, `body` and, when given, a `Retry-After`.
230    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    // ── ErrorResponse ───────────────────────────────────────────
264
265    #[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        // Cloudflare's IP block is plain text, not JSON.
280        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        // Surfaced, never slept on, so a week stays a week.
291        assert_eq!(at("604800"), Some(Duration::from_secs(604_800)));
292        // A zero is no wait (DRIFT R4), and junk is none.
293        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        // Not JSON, so the message is the body too.
305        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    // ── retriability, through the class ─────────────────────────
333
334    #[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        // A venue returns 425 while its matching engine restarts and documents
349        // it as "retry with exponential backoff".
350        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    // ── Classify ────────────────────────────────────────────────
377
378    /// One reqwest error of each kind the rule tells apart.
379    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        // No proxy, so a proxy set in the environment cannot answer for the
396        // local addresses below.
397        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        // A body that promises 100 bytes and stops after 6. reqwest reports
429        // the broken read as a decode error.
430        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        // Bind a port, then free it, so nothing is listening there.
449        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        // A listener that never accepts: the connection opens and no answer comes.
462        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        // (error, class, is_fault, retry_after()). The status decides a
495        // response's class, as it did for the variants it replaced.
496        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            // A region block is the venue answering as designed.
513            (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            // Was `Authentication`, built from a 401 or 403.
518            (
519                response(403, r#"{"error":"no"}"#, None).into(),
520                Class::Unauthorized,
521                true,
522                None,
523            ),
524            // Was `Validation`, for a server 400 or a local refusal. A local
525            // refusal is now `InvalidRequest`, and a server 400 the status's.
526            (
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            // Was `RateLimit`, which kept no Retry-After. A zero is no wait.
539            (
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            // Any status reports its wait, though only a 429's class has one.
554            (
555                response(503, "down", Some("5")).into(),
556                unavailable.clone(),
557                true,
558                secs(5),
559            ),
560            // Was `Timeout`, built only from a 408.
561            (
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            // Labelled a decode error by reqwest, but the connection broke.
575            (
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}