Skip to main content

toolkit_http/
error.rs

1use std::time::Duration;
2use thiserror::Error;
3
4/// Classification of URL validation failures.
5///
6/// Provides programmatic matching for different failure modes without
7/// relying on unstable error message strings.
8///
9/// # Example
10///
11/// ```ignore
12/// match &err {
13///     HttpError::InvalidUri { kind, .. } => match kind {
14///         InvalidUriKind::ParseError => println!("Malformed URL syntax"),
15///         InvalidUriKind::MissingAuthority => println!("URL needs a host"),
16///         InvalidUriKind::MissingScheme => println!("URL needs http:// or https://"),
17///         _ => println!("Other URI error"),
18///     },
19///     _ => {}
20/// }
21/// ```
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum InvalidUriKind {
25    /// URL could not be parsed (malformed syntax)
26    ParseError,
27    /// URL is missing required host/authority component
28    MissingAuthority,
29    /// URL is missing required scheme (http/https)
30    MissingScheme,
31}
32
33/// HTTP client error types
34#[derive(Error, Debug)]
35#[non_exhaustive]
36pub enum HttpError {
37    /// Request building failed
38    #[error("Failed to build request: {0}")]
39    RequestBuild(#[from] http::Error),
40
41    /// Invalid header name
42    #[error("Invalid header name: {0}")]
43    InvalidHeaderName(#[from] http::header::InvalidHeaderName),
44
45    /// Invalid header value
46    #[error("Invalid header value: {0}")]
47    InvalidHeaderValue(#[from] http::header::InvalidHeaderValue),
48
49    /// Single request attempt timed out
50    #[error("Request attempt timed out after {0:?}")]
51    Timeout(std::time::Duration),
52
53    /// Total operation deadline exceeded (including all retries)
54    #[error("Operation deadline exceeded after {0:?}")]
55    DeadlineExceeded(std::time::Duration),
56
57    /// Transport error (network, connection, etc)
58    #[error("Transport error: {0}")]
59    Transport(#[source] Box<dyn std::error::Error + Send + Sync>),
60
61    /// TLS error
62    #[error("TLS error: {0}")]
63    Tls(#[source] Box<dyn std::error::Error + Send + Sync>),
64
65    /// Response body exceeded size limit
66    #[error("Response body too large: limit {limit} bytes, got {actual} bytes")]
67    BodyTooLarge { limit: usize, actual: usize },
68
69    /// HTTP non-2xx status
70    #[error("HTTP {status}: {body_preview}")]
71    HttpStatus {
72        status: http::StatusCode,
73        body_preview: String,
74        content_type: Option<String>,
75        /// Parsed `Retry-After` header value, if present and valid
76        retry_after: Option<Duration>,
77    },
78
79    /// JSON parsing error
80    #[error("JSON parsing failed: {0}")]
81    Json(#[from] serde_json::Error),
82
83    /// Form URL encoding error
84    #[error("Form encoding failed: {0}")]
85    FormEncode(#[from] serde_urlencoded::ser::Error),
86
87    /// Service overloaded (concurrency limit reached, fail-fast)
88    #[error("Service overloaded: concurrency limit reached")]
89    Overloaded,
90
91    /// Internal service failure (buffer worker died, channel closed)
92    #[error("Service unavailable: internal failure")]
93    ServiceClosed,
94
95    /// Invalid URL (failed to parse)
96    ///
97    /// Use the `kind` field for programmatic matching. The `reason` field contains
98    /// a diagnostic message intended for logging only; do not match on its contents
99    /// as the format is unstable and may change between releases.
100    #[error("Invalid URL '{url}': {reason}")]
101    InvalidUri {
102        /// The URL that failed to parse
103        url: String,
104        /// Structured failure classification for programmatic matching
105        kind: InvalidUriKind,
106        /// Diagnostic message (unstable format, for logging only)
107        reason: String,
108    },
109
110    /// Invalid URL scheme for transport security configuration
111    #[error("URL scheme '{scheme}' not allowed: {reason}")]
112    InvalidScheme {
113        /// The URL scheme that was rejected
114        scheme: String,
115        /// Reason the scheme was rejected
116        reason: String,
117    },
118
119    /// Insecure transport (`AllowInsecureHttp`) was configured but the binary
120    /// was built with `--features fips`, which mandates `TlsOnly`.
121    ///
122    /// Emitted from `HttpClientBuilder::build()` under `cfg(feature = "fips")`.
123    /// The variant exists on all builds so callers can write a single `match`
124    /// arm, but it is only ever returned when the `fips` feature is active.
125    #[error("insecure transport (AllowInsecureHttp) is not permitted under FIPS")]
126    InsecureTransport,
127}
128
129impl From<hyper::Error> for HttpError {
130    fn from(err: hyper::Error) -> Self {
131        HttpError::Transport(Box::new(err))
132    }
133}
134
135impl From<hyper_util::client::legacy::Error> for HttpError {
136    fn from(err: hyper_util::client::legacy::Error) -> Self {
137        HttpError::Transport(Box::new(err))
138    }
139}
140
141#[cfg(test)]
142#[cfg_attr(coverage_nightly, coverage(off))]
143mod tests {
144    use super::*;
145    use std::error::Error;
146    use std::fmt;
147
148    #[derive(Debug)]
149    struct TestError(&'static str);
150
151    impl fmt::Display for TestError {
152        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153            write!(f, "{}", self.0)
154        }
155    }
156
157    impl Error for TestError {}
158
159    #[test]
160    fn test_transport_error_preserves_source() {
161        let inner = TestError("connection refused");
162        let err = HttpError::Transport(Box::new(inner));
163
164        // Verify source() returns the inner error
165        let source = err.source();
166        assert!(source.is_some(), "Transport error should have a source");
167
168        // Verify we can downcast to the original error type
169        let source = source.unwrap();
170        let downcast = source.downcast_ref::<TestError>();
171        assert!(
172            downcast.is_some(),
173            "Should be able to downcast to TestError"
174        );
175        assert_eq!(downcast.unwrap().0, "connection refused");
176    }
177
178    #[test]
179    fn test_tls_error_preserves_source() {
180        let inner = TestError("certificate expired");
181        let err = HttpError::Tls(Box::new(inner));
182
183        let source = err.source();
184        assert!(source.is_some(), "TLS error should have a source");
185
186        let source = source.unwrap();
187        let downcast = source.downcast_ref::<TestError>();
188        assert!(downcast.is_some());
189        assert_eq!(downcast.unwrap().0, "certificate expired");
190    }
191
192    #[test]
193    fn test_error_chain_traversal() {
194        let inner = TestError("root cause");
195        let err = HttpError::Transport(Box::new(inner));
196
197        // Count errors in chain
198        let mut count = 0;
199        let mut current: Option<&(dyn Error + 'static)> = Some(&err);
200        while let Some(e) = current {
201            count += 1;
202            current = e.source();
203        }
204
205        assert_eq!(
206            count, 2,
207            "Should have 2 errors in chain: HttpError and TestError"
208        );
209    }
210}