Skip to main content

crux_http/
error.rs

1use facet::Facet;
2use serde::{Deserialize, Serialize};
3use thiserror::Error as ThisError;
4
5/// An error produced when an HTTP request fails.
6///
7/// Variants fall into two groups:
8///
9/// **Transport errors** — generated by the shell when it cannot complete the HTTP
10/// exchange. These cross the FFI boundary and are serialized in the protocol:
11/// [`Url`](HttpError::Url), [`Io`](HttpError::Io), [`Timeout`](HttpError::Timeout).
12///
13/// **Processing errors** — generated on the Rust side after a response arrives.
14/// These are never serialized or visible to shells:
15///
16/// - [`Http`](HttpError::Http) — produced by `Response::new()` when the server returns
17///   a 4xx or 5xx status. At the *protocol* level these arrive as
18///   [`HttpResult::Ok`](crate::protocol::HttpResult::Ok); `Response::new()` converts
19///   them here, so app code using `crux_http::Result<Response<T>>` will see them as
20///   `Err(HttpError::Http { code, .. })`.
21/// - [`Json`](HttpError::Json) — produced when response body deserialisation fails.
22#[derive(Facet, Serialize, Deserialize, PartialEq, Eq, Clone, ThisError, Debug)]
23#[repr(C)]
24pub enum HttpError {
25    // Note: Url, Io, Timeout must come first to preserve discriminant order across the FFI.
26    /// The request URL could not be parsed.
27    #[error("URL parse error: {0}")]
28    Url(String),
29    /// An IO error prevented the request from completing.
30    #[error("IO error: {0}")]
31    Io(String),
32    /// The request timed out before a response was received.
33    #[error("Timeout")]
34    Timeout,
35
36    // Internal only — not serialized, never sent over the FFI boundary.
37    #[error("HTTP error {code}: {message}")]
38    #[serde(skip)]
39    #[facet(skip)]
40    Http {
41        code: u16,
42        message: String,
43        body: Option<Vec<u8>>,
44    },
45    #[error("JSON serialization error: {0}")]
46    #[serde(skip)]
47    #[facet(skip)]
48    Json(String),
49}
50
51#[cfg(feature = "http-types")]
52impl From<http_types::Error> for HttpError {
53    fn from(e: http_types::Error) -> Self {
54        Self::Http {
55            code: e.status().into(),
56            message: e.to_string(),
57            body: None,
58        }
59    }
60}
61
62impl From<std::io::Error> for HttpError {
63    fn from(e: std::io::Error) -> Self {
64        Self::Io(e.to_string())
65    }
66}
67
68impl From<serde_json::Error> for HttpError {
69    fn from(e: serde_json::Error) -> Self {
70        Self::Json(e.to_string())
71    }
72}
73
74impl From<url::ParseError> for HttpError {
75    fn from(e: url::ParseError) -> Self {
76        Self::Url(e.to_string())
77    }
78}
79
80impl From<serde_qs::Error> for HttpError {
81    fn from(e: serde_qs::Error) -> Self {
82        Self::Json(e.to_string())
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn test_error_display() {
92        let error = HttpError::Http {
93            code: 400,
94            message: "Bad Request".to_string(),
95            body: None,
96        };
97        assert_eq!(error.to_string(), "HTTP error 400: Bad Request");
98    }
99
100    #[test]
101    fn http_code_is_plain_u16() {
102        // The code field is a u16, so any valid status code literal works.
103        let error = HttpError::Http {
104            code: 404u16,
105            message: "Not Found".to_string(),
106            body: None,
107        };
108        assert_eq!(error.to_string(), "HTTP error 404: Not Found");
109    }
110
111    #[test]
112    fn io_error_converts_to_io_variant() {
113        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
114        let http_err = HttpError::from(io_err);
115        assert!(matches!(http_err, HttpError::Io(_)));
116        assert_eq!(http_err.to_string(), "IO error: file not found");
117    }
118
119    #[test]
120    fn serde_json_error_converts_to_json_variant() {
121        let json_err: serde_json::Error =
122            serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err();
123        let http_err = HttpError::from(json_err);
124        assert!(matches!(http_err, HttpError::Json(_)));
125    }
126
127    #[test]
128    fn url_parse_error_converts_to_url_variant() {
129        let url_err = url::Url::parse("not a url").unwrap_err();
130        let http_err = HttpError::from(url_err);
131        assert!(matches!(http_err, HttpError::Url(_)));
132    }
133
134    #[test]
135    fn serde_qs_error_converts_to_json_variant() {
136        let qs_err: serde_qs::Error =
137            serde_qs::from_str::<std::collections::HashMap<String, String>>("%bad%").unwrap_err();
138        let http_err = HttpError::from(qs_err);
139        assert!(matches!(http_err, HttpError::Json(_)));
140    }
141}