Skip to main content

polyoxide_core/
error.rs

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/// An unsuccessful response, read whole: what [`ApiError::Response`] carries.
12///
13/// The send loop builds one when the client's policy fails a response, and a
14/// venue's `From<ApiError>` decodes its own body shape from [`body`](Self::body).
15///
16/// Its `Debug`, which the `Request failed` ERROR line prints, names the
17/// headers without their values and truncates the message and the body.
18#[derive(Clone)]
19#[non_exhaustive]
20pub struct ErrorResponse {
21    /// The response's status.
22    pub status: StatusCode,
23    /// The response's headers.
24    pub headers: HeaderMap,
25    /// The response's body, verbatim, or `""` when it could not be read.
26    pub body: String,
27    /// The body's `error` field, else its `message` field, else the body
28    /// verbatim: the shape most venue hosts answer with.
29    pub message: String,
30    /// The response's `Retry-After`, read by
31    /// [`polyoxide_venue::parse_retry_after`] with no clamp, since it is
32    /// surfaced, never slept on. A zero is `None`.
33    pub retry_after: Option<Duration>,
34}
35
36impl ErrorResponse {
37    /// An unsuccessful response from its parts, its message and `Retry-After`
38    /// read from them.
39    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    /// Read `response` whole. A body that cannot be read is `""`.
64    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
73/// Written by hand so a log line never carries a header's value (a token, a
74/// cookie) or an unbounded body.
75impl 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/// Core API error types shared across Polyoxide clients
89#[derive(Error, Debug)]
90#[non_exhaustive]
91pub enum ApiError {
92    /// The server answered with an unsuccessful status. Its class is the
93    /// status's.
94    #[error("API error: {} - {}", .0.status.as_u16(), .0.message)]
95    Response(Box<ErrorResponse>),
96
97    /// The client refused the request before sending it: bad input, or
98    /// missing configuration.
99    #[error("Validation error: {0}")]
100    Validation(String),
101
102    /// The transport failed: no response arrived, or its body could not be
103    /// read. Its class is its kind's.
104    #[error("Transport error: {0}")]
105    Transport(#[from] TransportError),
106
107    /// JSON serialization/deserialization error
108    #[error("Serialization error: {0}")]
109    Serialization(#[from] serde_json::Error),
110
111    /// URL parsing error
112    #[error("URL error: {0}")]
113    Url(#[from] url::ParseError),
114
115    /// The throttle refused a cost no layer can ever hold, and nothing was
116    /// sent.
117    #[error("Refused before sending: {0}")]
118    Refused(#[from] Refused),
119
120    /// An [`Authenticator`](crate::Authenticator) could not sign the request,
121    /// and nothing was sent. Carries the venue's own error, which the venue's
122    /// `From<ApiError>` takes back out.
123    #[error("Signing failed: {0}")]
124    Sign(Box<dyn std::error::Error + Send + Sync>),
125}
126
127impl ApiError {
128    /// Read an unsuccessful response whole, as [`ApiError::Response`].
129    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
140/// The status rule, with any status outside 400–599 a [`Class::Decode`].
141fn status_class(status: u16) -> Class {
142    class_for_status(status).unwrap_or(Class::Decode)
143}
144
145/// The class each variant reports.
146///
147/// [`ApiError::Response`] follows the status rule, with any status outside
148/// 400–599 a [`Class::Decode`] (a 2xx that a caller found wanting, such as a
149/// ping whose body says it is not ok), and a `429`'s wait its `Retry-After`.
150/// A 451 is not a fault: the venue does not serve the caller's region, as
151/// designed. [`ApiError::Validation`] and [`ApiError::Sign`] are a
152/// [`Class::InvalidRequest`]: the client refused the request, or could not
153/// sign it, and nothing was sent.
154///
155/// [`Classify::retry_after`] is a response's `Retry-After` whatever its
156/// status, and `None` for every other variant.
157impl 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    /// A response with `status`, `body` and, when given, a `Retry-After`.
191    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    /// A transport failure of `kind`, caused by `source`.
204    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    // ── ErrorResponse ───────────────────────────────────────────
233
234    #[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        // Cloudflare's IP block is plain text, not JSON.
249        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        // Surfaced, never slept on, so a week stays a week.
260        assert_eq!(at("604800"), Some(Duration::from_secs(604_800)));
261        // A zero is no wait (DRIFT R4), and junk is none.
262        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        // Not JSON, so the message is the body too.
274        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    // ── retriability, through the class ─────────────────────────
302
303    #[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        // A venue returns 425 while its matching engine restarts and documents
318        // it as "retry with exponential backoff".
319        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    // ── Classify ────────────────────────────────────────────────
346
347    /// The source text of a transport failure of each kind a transport
348    /// reports. The names stay as the baseline's, whose items held real
349    /// reqwest errors; a `TransportError` only displays its source.
350    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        // (error, class, is_fault, retry_after()). The status decides a
376        // response's class, as it did for the variants it replaced.
377        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            // A region block is the venue answering as designed.
394            (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            // Was `Authentication`, built from a 401 or 403.
399            (
400                response(403, r#"{"error":"no"}"#, None).into(),
401                Class::Unauthorized,
402                true,
403                None,
404            ),
405            // Was `Validation`, for a server 400 or a local refusal. A local
406            // refusal is now `InvalidRequest`, and a server 400 the status's.
407            (
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            // Was `RateLimit`, which kept no Retry-After. A zero is no wait.
420            (
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            // Any status reports its wait, though only a 429's class has one.
435            (
436                response(503, "down", Some("5")).into(),
437                unavailable.clone(),
438                true,
439                secs(5),
440            ),
441            // Was `Timeout`, built only from a 408.
442            (
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            // A body that broke off is a failure in transit, so `Network`, though reqwest
455            // labels it a decode error.
456            (
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}