Skip to main content

modkit_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
120impl From<hyper::Error> for HttpError {
121    fn from(err: hyper::Error) -> Self {
122        HttpError::Transport(Box::new(err))
123    }
124}
125
126impl From<hyper_util::client::legacy::Error> for HttpError {
127    fn from(err: hyper_util::client::legacy::Error) -> Self {
128        HttpError::Transport(Box::new(err))
129    }
130}
131
132#[cfg(test)]
133#[cfg_attr(coverage_nightly, coverage(off))]
134mod tests {
135    use super::*;
136    use std::error::Error;
137    use std::fmt;
138
139    #[derive(Debug)]
140    struct TestError(&'static str);
141
142    impl fmt::Display for TestError {
143        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144            write!(f, "{}", self.0)
145        }
146    }
147
148    impl Error for TestError {}
149
150    #[test]
151    fn test_transport_error_preserves_source() {
152        let inner = TestError("connection refused");
153        let err = HttpError::Transport(Box::new(inner));
154
155        // Verify source() returns the inner error
156        let source = err.source();
157        assert!(source.is_some(), "Transport error should have a source");
158
159        // Verify we can downcast to the original error type
160        let source = source.unwrap();
161        let downcast = source.downcast_ref::<TestError>();
162        assert!(
163            downcast.is_some(),
164            "Should be able to downcast to TestError"
165        );
166        assert_eq!(downcast.unwrap().0, "connection refused");
167    }
168
169    #[test]
170    fn test_tls_error_preserves_source() {
171        let inner = TestError("certificate expired");
172        let err = HttpError::Tls(Box::new(inner));
173
174        let source = err.source();
175        assert!(source.is_some(), "TLS error should have a source");
176
177        let source = source.unwrap();
178        let downcast = source.downcast_ref::<TestError>();
179        assert!(downcast.is_some());
180        assert_eq!(downcast.unwrap().0, "certificate expired");
181    }
182
183    #[test]
184    fn test_error_chain_traversal() {
185        let inner = TestError("root cause");
186        let err = HttpError::Transport(Box::new(inner));
187
188        // Count errors in chain
189        let mut count = 0;
190        let mut current: Option<&(dyn Error + 'static)> = Some(&err);
191        while let Some(e) = current {
192            count += 1;
193            current = e.source();
194        }
195
196        assert_eq!(
197            count, 2,
198            "Should have 2 errors in chain: HttpError and TestError"
199        );
200    }
201}