Skip to main content

crux_http/
error.rs

1use facet::Facet;
2use http::{HeaderMap, HeaderValue};
3use serde::{Deserialize, Serialize, de::DeserializeOwned};
4use thiserror::Error as ThisError;
5
6use crate::Result;
7
8/// An error produced when an HTTP request fails.
9///
10/// Variants fall into two groups:
11///
12/// **Transport errors** — generated by the shell when it cannot complete the HTTP
13/// exchange. These cross the FFI boundary and are serialized in the protocol:
14/// [`Url`](HttpError::Url), [`Io`](HttpError::Io), [`Timeout`](HttpError::Timeout).
15///
16/// **Processing errors** — generated on the Rust side after a response arrives.
17/// These are never serialized or visible to shells:
18///
19/// - [`Http`](HttpError::Http) — produced by `Response::new()` when the server returns
20///   a 4xx or 5xx status, and **only** then. At the *protocol* level these arrive as
21///   [`HttpResult::Ok`](crate::protocol::HttpResult::Ok); `Response::new()` converts
22///   them here, so app code using `crux_http::Result<Response<T>>` will see them as
23///   `Err(HttpError::Http { code, .. })` — **never** as `Ok(response)` with an error
24///   status.
25/// - [`Json`](HttpError::Json) — produced when response body deserialisation fails.
26/// - [`BodyAlreadyTaken`](HttpError::BodyAlreadyTaken) — a caller took the response body
27///   twice.
28/// - [`InvalidStatusCode`](HttpError::InvalidStatusCode) — the shell sent a status that
29///   isn't valid HTTP.
30///
31/// # Reading what the server said
32///
33/// Because a rejection arrives as an error rather than a response, everything the server
34/// sent with it lives on this type: [`HttpError::body`] and [`HttpError::body_json`] for
35/// the body, [`HttpError::header`] and [`HttpError::content_type`] for the headers. No
36/// matching on the variant's fields required:
37///
38/// ```
39/// # use serde::Deserialize;
40/// #[derive(Deserialize)]
41/// struct ApiError {
42///     error: String,
43/// }
44///
45/// // the `crux_http::Result` a feature receives when the server rejects a request
46/// let result: crux_http::Result<crux_http::Response<Vec<u8>>> =
47///     crux_http::testing::rejection(409, r#"{"error":"that overlaps a booked day"}"#);
48///
49/// let error = result.expect_err("a 409 is never Ok");
50/// assert_eq!(error.code(), Some(409));
51/// assert_eq!(
52///     error.body_json::<ApiError>().unwrap().error,
53///     "that overlaps a booked day"
54/// );
55/// ```
56#[derive(Facet, Serialize, Deserialize, PartialEq, Eq, Clone, ThisError, Debug)]
57#[repr(C)]
58pub enum HttpError {
59    // Note: Url, Io, Timeout must come first to preserve discriminant order across the FFI.
60    /// The request URL could not be parsed.
61    #[error("URL parse error: {0}")]
62    Url(String),
63    /// An IO error prevented the request from completing.
64    #[error("IO error: {0}")]
65    Io(String),
66    /// The request timed out before a response was received.
67    #[error("Timeout")]
68    Timeout,
69
70    // Internal only — not serialized, never sent over the FFI boundary.
71    /// The exchange completed, but the server answered with a 4xx or 5xx status.
72    ///
73    /// This is what a feature receives *instead of* a [`Response`](crate::Response)
74    /// when the server rejects a request, so it is the only place a rejection can be
75    /// handled — and a rejection is the only thing that produces it. Nothing else in the
76    /// crate raises an `Http`, so `matches!(error, HttpError::Http { .. })` (or, more
77    /// simply, [`code`](HttpError::code) returning `Some`) means the server said no.
78    ///
79    /// `body` is the response body exactly as the server sent it. Body decoding
80    /// (`expect_json`, `expect_string`) is skipped for an error status, so a server's
81    /// error envelope — `{"error": "…"}`, an RFC 7807 `problem+json` document, a plain
82    /// sentence — survives here even for a request built with
83    /// [`expect_json`](crate::command::RequestBuilder::expect_json). Read it with
84    /// [`HttpError::body`] or [`HttpError::body_json`] rather than destructuring.
85    ///
86    /// `code` is the status code and `message` its canonical reason phrase (e.g.
87    /// `"409 Conflict"`) — a description of the status, never the server's own
88    /// explanation. [`Display`](std::fmt::Display) shows only those two, so an app that
89    /// reports `error.to_string()` to a user shows `"HTTP error 409: 409 Conflict"` and
90    /// discards whatever the server took the trouble to say.
91    ///
92    /// `headers` are the rejected response's headers, because a rejection's *policy* often
93    /// lives there and nowhere else: `Retry-After` on a 429 or 503, `WWW-Authenticate` on
94    /// a 401, rate-limit headers, or the `Content-Type` that says whether the body is JSON,
95    /// HTML or prose. Read them with [`HttpError::header`], [`HttpError::content_type`] or
96    /// [`HttpError::headers`]. A rejection that sent no headers has an empty map, not an
97    /// absent one — there was always a response here, which is what distinguishes this
98    /// variant from every other.
99    ///
100    /// (The map is boxed only to keep `HttpError` small: a bare `HeaderMap` is 96 bytes, and
101    /// this type is the `Err` of nearly every function in the crate.)
102    ///
103    /// The variant is `#[non_exhaustive]`: only `crux_http` constructs it, so that what a
104    /// rejection carries can grow without breaking you. Match it with `{ code, .. }` and
105    /// read the rest through the accessors above; in tests, build one with
106    /// [`rejection`](crate::testing::rejection) or
107    /// [`rejection_from`](crate::testing::rejection_from).
108    #[error("HTTP error {code}: {message}")]
109    #[serde(skip)]
110    #[facet(skip)]
111    #[non_exhaustive]
112    Http {
113        code: u16,
114        message: String,
115        #[facet(opaque)]
116        headers: Box<HeaderMap>,
117        body: Vec<u8>,
118    },
119    /// A response body could not be deserialized (or a request body serialized).
120    #[error("JSON serialization error: {0}")]
121    #[serde(skip)]
122    #[facet(skip)]
123    Json(String),
124
125    /// The response body had already been taken.
126    ///
127    /// A caller error rather than anything the server did: [`body_bytes`], [`body_string`]
128    /// and [`body_json`] all *take* the body, so only the first call can succeed. It carries
129    /// nothing, because the caller still holds the [`Response`](crate::Response) and so
130    /// already has its status and headers.
131    ///
132    /// [`body_bytes`]: crate::Response::body_bytes
133    /// [`body_string`]: crate::Response::body_string
134    /// [`body_json`]: crate::Response::body_json
135    #[error("response body had already been taken")]
136    #[serde(skip)]
137    #[facet(skip)]
138    BodyAlreadyTaken,
139
140    /// The shell sent a status code that is not valid HTTP (outside the range 100–999).
141    ///
142    /// The response never became one, so there is nothing to read from it. This means the
143    /// shell is misbehaving, not that the request was rejected.
144    #[error("invalid HTTP status code: {0}")]
145    #[serde(skip)]
146    #[facet(skip)]
147    InvalidStatusCode(u16),
148}
149
150impl HttpError {
151    /// The status the server rejected the request with.
152    ///
153    /// `Some` if and only if this error *is* a rejection — nothing else in the crate carries
154    /// a status. `None` covers everything the server didn't decide: transport failures, a
155    /// body that wouldn't deserialize, a body taken twice, and a status the shell sent that
156    /// wasn't valid HTTP (that one keeps its number on
157    /// [`InvalidStatusCode`](HttpError::InvalidStatusCode), which is not a status a server
158    /// ever answered with).
159    ///
160    /// # Examples
161    ///
162    /// ```
163    /// # use crux_http::HttpError;
164    /// let error: HttpError = crux_http::testing::rejection::<Vec<u8>>(404, "")
165    ///     .expect_err("a 404 is never Ok");
166    /// assert_eq!(error.code(), Some(404));
167    ///
168    /// assert_eq!(HttpError::Timeout.code(), None);
169    /// assert_eq!(HttpError::InvalidStatusCode(999).code(), None);
170    /// ```
171    #[must_use]
172    pub const fn code(&self) -> Option<u16> {
173        match self {
174            Self::Http { code, .. } => Some(*code),
175            _ => None,
176        }
177    }
178
179    /// The raw response body that came with an error status, if there was one.
180    ///
181    /// This is the server's own account of why it rejected the request — usually far
182    /// more useful to a user than [`Display`](std::fmt::Display), which can only report
183    /// the status. `None` for anything that isn't a rejection, and for a rejection whose
184    /// body was empty — every real 4xx arrives with bytes, and `Some(&[])` would only make
185    /// the obvious `if let Some(body)` idiom display blanks.
186    ///
187    /// # Examples
188    ///
189    /// ```
190    /// let error = crux_http::testing::rejection::<Vec<u8>>(422, "name is required")
191    ///     .expect_err("a 422 is never Ok");
192    /// assert_eq!(error.body(), Some(&b"name is required"[..]));
193    /// ```
194    #[must_use]
195    pub fn body(&self) -> Option<&[u8]> {
196        match self {
197            Self::Http { body, .. } if !body.is_empty() => Some(body),
198            _ => None,
199        }
200    }
201
202    /// A header from the rejected response.
203    ///
204    /// Some of what an app needs to *act on* a rejection is only in the headers:
205    /// `Retry-After` on a 429 or 503, `WWW-Authenticate` on a 401, rate-limit headers.
206    /// None of it can be recovered from the status or the body.
207    ///
208    /// Returns `None` for anything that isn't a rejection, and for a name the rejected
209    /// response didn't carry. Lookup is case-insensitive, as HTTP requires.
210    ///
211    /// # Examples
212    ///
213    /// ```
214    /// # use crux_http::{protocol::HttpResponse, testing::rejection_from};
215    /// let error = rejection_from::<Vec<u8>>(
216    ///     HttpResponse::status(429).header("retry-after", "30").build(),
217    /// )
218    /// .expect_err("a 429 is never Ok");
219    ///
220    /// assert_eq!(error.header("Retry-After").unwrap(), "30");
221    /// ```
222    #[must_use]
223    pub fn header(&self, name: impl http::header::AsHeaderName) -> Option<&HeaderValue> {
224        self.headers()?.get(name)
225    }
226
227    /// All headers from the rejected response.
228    ///
229    /// The escape hatch for anything [`HttpError::header`] and
230    /// [`HttpError::content_type`] don't cover — multi-value headers, iteration, or
231    /// forwarding the lot to a logger.
232    ///
233    /// `Some` for a rejection — possibly an empty map, if the server sent no headers — and
234    /// `None` for every other error, none of which had a response behind them.
235    #[must_use]
236    pub fn headers(&self) -> Option<&HeaderMap> {
237        match self {
238            Self::Http { headers, .. } => Some(headers),
239            _ => None,
240        }
241    }
242
243    /// The content type of the error response body, if it declared one.
244    ///
245    /// Tells an error envelope (`application/json`) from an RFC 7807 document
246    /// (`application/problem+json`) from a proxy's HTML page (`text/html`) — which decides
247    /// whether it is worth handing the body to [`HttpError::body_json`], and whether the
248    /// body is safe to show a user as text.
249    ///
250    /// # Examples
251    ///
252    /// ```
253    /// # use crux_http::{protocol::HttpResponse, testing::rejection_from};
254    /// let error = rejection_from::<Vec<u8>>(
255    ///     HttpResponse::status(400)
256    ///         .header("content-type", "application/problem+json")
257    ///         .body(br#"{"detail":"nope"}"#.to_vec())
258    ///         .build(),
259    /// )
260    /// .expect_err("a 400 is never Ok");
261    ///
262    /// assert_eq!(
263    ///     error.content_type().map(|mime| mime.to_string()),
264    ///     Some("application/problem+json".to_string())
265    /// );
266    /// ```
267    #[must_use]
268    pub fn content_type(&self) -> Option<mime::Mime> {
269        self.header(http::header::CONTENT_TYPE)?
270            .to_str()
271            .ok()?
272            .parse()
273            .ok()
274    }
275
276    /// Deserialize the error response body from JSON.
277    ///
278    /// Works for any JSON error shape — deserialize into your API's own envelope, or
279    /// into an RFC 7807 `problem+json` struct, or into [`serde_json::Value`] if you
280    /// don't know which you'll get.
281    ///
282    /// # Errors
283    ///
284    /// Returns [`HttpError::Json`] if the error carries no body, or if the body is not
285    /// valid JSON for `T`.
286    ///
287    /// # Examples
288    ///
289    /// ```
290    /// # use serde::Deserialize;
291    /// #[derive(Deserialize)]
292    /// struct Problem {
293    ///     detail: String,
294    /// }
295    ///
296    /// let error = crux_http::testing::rejection::<Vec<u8>>(
297    ///     409,
298    ///     r#"{"title":"Conflict","detail":"that would create a management cycle"}"#,
299    /// )
300    /// .expect_err("a 409 is never Ok");
301    ///
302    /// let problem: Problem = error.body_json().unwrap();
303    /// assert_eq!(problem.detail, "that would create a management cycle");
304    /// ```
305    pub fn body_json<T: DeserializeOwned>(&self) -> Result<T> {
306        let body = self
307            .body()
308            .ok_or_else(|| Self::Json("error has no response body".to_string()))?;
309        serde_json::from_slice(body).map_err(Self::from)
310    }
311}
312
313impl From<std::io::Error> for HttpError {
314    fn from(e: std::io::Error) -> Self {
315        Self::Io(e.to_string())
316    }
317}
318
319impl From<serde_json::Error> for HttpError {
320    fn from(e: serde_json::Error) -> Self {
321        Self::Json(e.to_string())
322    }
323}
324
325impl From<url::ParseError> for HttpError {
326    fn from(e: url::ParseError) -> Self {
327        Self::Url(e.to_string())
328    }
329}
330
331impl From<serde_qs::Error> for HttpError {
332    fn from(e: serde_qs::Error) -> Self {
333        Self::Json(e.to_string())
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn test_error_display() {
343        let error = HttpError::Http {
344            code: 400,
345            message: "Bad Request".to_string(),
346            headers: Box::default(),
347            body: vec![],
348        };
349        assert_eq!(error.to_string(), "HTTP error 400: Bad Request");
350    }
351
352    #[test]
353    fn http_code_is_plain_u16() {
354        // The code field is a u16, so any valid status code literal works.
355        let error = HttpError::Http {
356            code: 404u16,
357            message: "Not Found".to_string(),
358            headers: Box::default(),
359            body: vec![],
360        };
361        assert_eq!(error.to_string(), "HTTP error 404: Not Found");
362    }
363
364    #[test]
365    fn io_error_converts_to_io_variant() {
366        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
367        let http_err = HttpError::from(io_err);
368        assert!(matches!(http_err, HttpError::Io(_)));
369        assert_eq!(http_err.to_string(), "IO error: file not found");
370    }
371
372    #[test]
373    fn serde_json_error_converts_to_json_variant() {
374        let json_err: serde_json::Error =
375            serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err();
376        let http_err = HttpError::from(json_err);
377        assert!(matches!(http_err, HttpError::Json(_)));
378    }
379
380    #[test]
381    fn url_parse_error_converts_to_url_variant() {
382        let url_err = url::Url::parse("not a url").unwrap_err();
383        let http_err = HttpError::from(url_err);
384        assert!(matches!(http_err, HttpError::Url(_)));
385    }
386
387    #[test]
388    fn accessors_read_the_error_response() {
389        let mut headers = HeaderMap::new();
390        headers.insert(
391            http::header::CONTENT_TYPE,
392            HeaderValue::from_static("application/json"),
393        );
394        let error = HttpError::Http {
395            code: 409,
396            message: "409 Conflict".to_string(),
397            headers: Box::new(headers),
398            body: br#"{"error":"already booked"}"#.to_vec(),
399        };
400
401        assert_eq!(error.code(), Some(409));
402        assert_eq!(error.body(), Some(&br#"{"error":"already booked"}"#[..]));
403        let body: serde_json::Value = error.body_json().expect("body is JSON");
404        assert_eq!(body["error"], "already booked");
405        assert_eq!(error.content_type(), Some(mime::APPLICATION_JSON));
406        // HTTP header names are case-insensitive; HeaderMap handles that for us.
407        assert_eq!(error.header("Content-Type").unwrap(), "application/json");
408        assert_eq!(error.header("x-absent"), None);
409    }
410
411    #[test]
412    fn there_are_no_headers_without_a_response() {
413        // A transport error never had a response, so there is nothing to describe.
414        assert_eq!(HttpError::Timeout.header("retry-after"), None);
415        assert_eq!(HttpError::Timeout.headers(), None);
416        assert_eq!(HttpError::Timeout.content_type(), None);
417
418        // Nor does a status the crate rejected before a response existed. That is no longer
419        // an `Http` at all, which is the point of the variant: it was never a rejection.
420        let error = HttpError::InvalidStatusCode(999);
421        assert_eq!(error.header("retry-after"), None);
422        assert_eq!(error.headers(), None);
423        assert_eq!(error.content_type(), None);
424
425        // Whereas a rejection always has a header map, even when the server sent nothing in
426        // it — so `headers()` is `Some(&empty)` here, never `None`. That is the whole
427        // distinction the accessor draws: `None` means "not a rejection".
428        let error = HttpError::Http {
429            code: 500,
430            message: "500 Internal Server Error".to_string(),
431            headers: Box::default(),
432            body: vec![],
433        };
434        assert!(error.headers().expect("a rejection has headers").is_empty());
435        assert_eq!(error.content_type(), None);
436    }
437
438    /// `Retry-After` is the case that cannot be recovered any other way: it is not in the
439    /// status, and not in the body.
440    #[test]
441    fn retry_after_survives_on_the_error() {
442        let error = crate::testing::rejection_from::<Vec<u8>>(
443            crate::HttpResponse::status(429)
444                .header("retry-after", "30")
445                .build(),
446        )
447        .expect_err("a 429 is never Ok");
448
449        assert_eq!(error.header("retry-after").unwrap(), "30");
450    }
451
452    #[test]
453    fn accessors_are_empty_for_errors_without_a_response() {
454        // A transport error never had a response to read.
455        let error = HttpError::Timeout;
456        assert_eq!(error.code(), None);
457        assert_eq!(error.body(), None);
458        assert!(matches!(
459            error.body_json::<serde_json::Value>(),
460            Err(HttpError::Json(_))
461        ));
462
463        // Nor do the two errors the crate raises itself, neither of which is a rejection.
464        assert_eq!(HttpError::BodyAlreadyTaken.body(), None);
465        assert_eq!(HttpError::InvalidStatusCode(999).body(), None);
466
467        // A rejection that sent no body still reports its status.
468        let error = HttpError::Http {
469            code: 500,
470            message: "500 Internal Server Error".to_string(),
471            headers: Box::default(),
472            body: vec![],
473        };
474        assert_eq!(error.code(), Some(500));
475        assert_eq!(error.body(), None);
476    }
477
478    /// The invariant this split exists to establish: a status on the error means the server
479    /// rejected the request, and nothing else does.
480    #[test]
481    fn only_a_rejection_has_a_code() {
482        assert_eq!(
483            crate::testing::rejection::<Vec<u8>>(409, "")
484                .expect_err("a 409 is never Ok")
485                .code(),
486            Some(409)
487        );
488
489        assert_eq!(HttpError::BodyAlreadyTaken.code(), None);
490        assert_eq!(HttpError::InvalidStatusCode(999).code(), None);
491        assert_eq!(HttpError::Timeout.code(), None);
492        assert_eq!(HttpError::Io("refused".to_string()).code(), None);
493        assert_eq!(HttpError::Url("bad".to_string()).code(), None);
494        assert_eq!(HttpError::Json("nope".to_string()).code(), None);
495    }
496
497    #[test]
498    fn the_new_variants_display_usefully() {
499        assert_eq!(
500            HttpError::BodyAlreadyTaken.to_string(),
501            "response body had already been taken"
502        );
503        assert_eq!(
504            HttpError::InvalidStatusCode(999).to_string(),
505            "invalid HTTP status code: 999"
506        );
507    }
508
509    #[test]
510    fn an_empty_body_reads_as_no_body() {
511        let error = HttpError::Http {
512            code: 404,
513            message: "404 Not Found".to_string(),
514            headers: Box::default(),
515            body: vec![],
516        };
517        assert_eq!(error.body(), None);
518    }
519
520    #[test]
521    fn body_json_reports_a_body_that_is_not_json() {
522        let error = HttpError::Http {
523            code: 502,
524            message: "502 Bad Gateway".to_string(),
525            headers: Box::default(),
526            body: b"<html>nginx</html>".to_vec(),
527        };
528        assert!(matches!(
529            error.body_json::<serde_json::Value>(),
530            Err(HttpError::Json(_))
531        ));
532        // ... but the raw body is still there to log or display.
533        assert_eq!(error.body(), Some(&b"<html>nginx</html>"[..]));
534    }
535
536    #[test]
537    fn serde_qs_error_converts_to_json_variant() {
538        let qs_err: serde_qs::Error =
539            serde_qs::from_str::<std::collections::HashMap<String, String>>("%bad%").unwrap_err();
540        let http_err = HttpError::from(qs_err);
541        assert!(matches!(http_err, HttpError::Json(_)));
542    }
543}