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}