Skip to main content

oxihttp_core/
error.rs

1//! Error types for the OxiHTTP stack.
2
3use std::sync::Arc;
4
5use thiserror::Error;
6
7/// Top-level error type for the OxiHTTP stack.
8#[derive(Debug, Clone, Error)]
9pub enum OxiHttpError {
10    /// An invalid URI was provided.
11    #[error("invalid URI: {0}")]
12    InvalidUri(Arc<http::uri::InvalidUri>),
13
14    /// An HTTP protocol error.
15    #[error("HTTP error: {0}")]
16    Http(Arc<http::Error>),
17
18    /// A hyper transport error, captured as a string to avoid exposing hyper's types.
19    #[error("hyper error: {0}")]
20    Hyper(String),
21
22    /// An I/O error.
23    #[error("I/O error: {0}")]
24    Io(Arc<std::io::Error>),
25
26    /// An error reading or processing the response body.
27    #[error("body error: {0}")]
28    Body(String),
29
30    /// A request or connect timeout expired.
31    #[error("timeout: {0}")]
32    Timeout(String),
33
34    /// A redirect loop or limit was reached.
35    #[error("redirect error: {0}")]
36    Redirect(String),
37
38    /// A TLS-specific error from oxitls.
39    #[error("TLS error: {0}")]
40    Tls(String),
41
42    /// A DNS resolution failure.
43    #[error("DNS error: {0}")]
44    Dns(String),
45
46    /// Connection pool exhaustion.
47    #[error("connection pool error: {0}")]
48    ConnectionPool(String),
49
50    /// JSON serialization/deserialization error.
51    #[error("JSON error: {0}")]
52    Json(String),
53
54    /// URL-encoded form error.
55    #[error("form encoding error: {0}")]
56    FormEncoding(String),
57
58    /// An invalid header name or value.
59    #[error("invalid header: {0}")]
60    InvalidHeader(String),
61
62    /// A server-specific error.
63    #[error("server error: {0}")]
64    Server(String),
65
66    /// Route not found (404).
67    #[error("route not found: {method} {path}")]
68    RouteNotFound {
69        /// The HTTP method of the request.
70        method: String,
71        /// The path that was not found.
72        path: String,
73    },
74
75    /// Method not allowed (405).
76    #[error("method not allowed: {method} {path}")]
77    MethodNotAllowed {
78        /// The HTTP method that is not allowed.
79        method: String,
80        /// The path where the method is not allowed.
81        path: String,
82    },
83
84    /// An HTTP/3 / QUIC transport error (oxiquic-h3).
85    #[error("HTTP/3 error: {0}")]
86    H3(String),
87}
88
89impl From<http::uri::InvalidUri> for OxiHttpError {
90    fn from(e: http::uri::InvalidUri) -> Self {
91        OxiHttpError::InvalidUri(Arc::new(e))
92    }
93}
94
95impl From<std::io::Error> for OxiHttpError {
96    fn from(e: std::io::Error) -> Self {
97        OxiHttpError::Io(Arc::new(e))
98    }
99}
100
101impl From<http::Error> for OxiHttpError {
102    fn from(e: http::Error) -> Self {
103        OxiHttpError::Http(Arc::new(e))
104    }
105}
106
107#[cfg(feature = "tls")]
108impl From<oxitls_core::TlsError> for OxiHttpError {
109    fn from(e: oxitls_core::TlsError) -> Self {
110        OxiHttpError::Tls(e.to_string())
111    }
112}
113
114impl OxiHttpError {
115    /// Returns the HTTP status code associated with this error, if any.
116    pub fn status_code(&self) -> Option<http::StatusCode> {
117        match self {
118            Self::RouteNotFound { .. } => Some(http::StatusCode::NOT_FOUND),
119            Self::MethodNotAllowed { .. } => Some(http::StatusCode::METHOD_NOT_ALLOWED),
120            Self::Timeout(_) => Some(http::StatusCode::REQUEST_TIMEOUT),
121            _ => None,
122        }
123    }
124
125    /// Returns `true` if this is a timeout error.
126    pub fn is_timeout(&self) -> bool {
127        matches!(self, Self::Timeout(_))
128    }
129
130    /// Returns `true` if this is a connection-related error.
131    pub fn is_connect(&self) -> bool {
132        matches!(self, Self::Dns(_) | Self::ConnectionPool(_) | Self::Tls(_))
133    }
134
135    /// Returns `true` if this is a body reading error.
136    pub fn is_body(&self) -> bool {
137        matches!(self, Self::Body(_))
138    }
139
140    /// Returns `true` if this is a redirect error.
141    pub fn is_redirect(&self) -> bool {
142        matches!(self, Self::Redirect(_))
143    }
144}
145
146#[cfg(test)]
147mod clone_tests {
148    use super::*;
149
150    #[test]
151    fn test_oxi_http_error_is_clone() {
152        let io_err = OxiHttpError::from(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
153        let cloned = io_err.clone();
154        assert_eq!(io_err.to_string(), cloned.to_string());
155
156        let str_err = OxiHttpError::Body("test".to_string());
157        let _ = str_err.clone();
158    }
159}
160
161#[cfg(test)]
162mod error_tests {
163    use super::*;
164
165    // -------------------------------------------------------------------------
166    // Display formatting tests
167    // -------------------------------------------------------------------------
168
169    #[test]
170    fn test_display_invalid_uri() {
171        let raw_err: http::uri::InvalidUri = "not a valid uri!!!"
172            .parse::<http::Uri>()
173            .expect_err("should fail to parse");
174        let err = OxiHttpError::from(raw_err);
175        let msg = err.to_string();
176        assert!(
177            msg.contains("invalid URI"),
178            "expected 'invalid URI' in '{msg}'"
179        );
180    }
181
182    #[test]
183    fn test_display_http_error() {
184        let raw_err = http::Request::builder()
185            .header("\n", "x")
186            .body(())
187            .expect_err("should fail with invalid header name");
188        let err = OxiHttpError::from(raw_err);
189        let msg = err.to_string();
190        assert!(
191            msg.contains("HTTP error"),
192            "expected 'HTTP error' in '{msg}'"
193        );
194    }
195
196    #[test]
197    fn test_display_hyper_error() {
198        let err = OxiHttpError::Hyper("connection reset".to_string());
199        let msg = err.to_string();
200        assert!(
201            msg.contains("hyper error"),
202            "expected 'hyper error' in '{msg}'"
203        );
204    }
205
206    #[test]
207    fn test_display_io_error() {
208        let raw_err = std::io::Error::new(
209            std::io::ErrorKind::ConnectionRefused,
210            "connection refused test",
211        );
212        let err = OxiHttpError::from(raw_err);
213        let msg = err.to_string();
214        assert!(msg.contains("I/O error"), "expected 'I/O error' in '{msg}'");
215    }
216
217    #[test]
218    fn test_display_body_error() {
219        let err = OxiHttpError::Body("chunk too large".to_string());
220        let msg = err.to_string();
221        assert!(
222            msg.contains("body error"),
223            "expected 'body error' in '{msg}'"
224        );
225    }
226
227    #[test]
228    fn test_display_timeout() {
229        let err = OxiHttpError::Timeout("request timed out".to_string());
230        let msg = err.to_string();
231        assert!(msg.contains("timeout"), "expected 'timeout' in '{msg}'");
232    }
233
234    #[test]
235    fn test_display_redirect() {
236        let err = OxiHttpError::Redirect("too many redirects".to_string());
237        let msg = err.to_string();
238        assert!(
239            msg.contains("redirect error"),
240            "expected 'redirect error' in '{msg}'"
241        );
242    }
243
244    #[test]
245    fn test_display_tls() {
246        let err = OxiHttpError::Tls("certificate invalid".to_string());
247        let msg = err.to_string();
248        assert!(msg.contains("TLS error"), "expected 'TLS error' in '{msg}'");
249    }
250
251    #[test]
252    fn test_display_dns() {
253        let err = OxiHttpError::Dns("no such host".to_string());
254        let msg = err.to_string();
255        assert!(msg.contains("DNS error"), "expected 'DNS error' in '{msg}'");
256    }
257
258    #[test]
259    fn test_display_connection_pool() {
260        let err = OxiHttpError::ConnectionPool("pool exhausted".to_string());
261        let msg = err.to_string();
262        assert!(
263            msg.contains("connection pool error"),
264            "expected 'connection pool error' in '{msg}'"
265        );
266    }
267
268    #[test]
269    fn test_display_json() {
270        let err = OxiHttpError::Json("unexpected token".to_string());
271        let msg = err.to_string();
272        assert!(
273            msg.contains("JSON error"),
274            "expected 'JSON error' in '{msg}'"
275        );
276    }
277
278    #[test]
279    fn test_display_route_not_found() {
280        let err = OxiHttpError::RouteNotFound {
281            method: "GET".to_string(),
282            path: "/foo".to_string(),
283        };
284        let msg = err.to_string();
285        assert!(
286            msg.contains("route not found"),
287            "expected 'route not found' in '{msg}'"
288        );
289        assert!(msg.contains("GET"), "expected 'GET' in '{msg}'");
290        assert!(msg.contains("/foo"), "expected '/foo' in '{msg}'");
291    }
292
293    #[test]
294    fn test_display_method_not_allowed() {
295        let err = OxiHttpError::MethodNotAllowed {
296            method: "DELETE".to_string(),
297            path: "/bar".to_string(),
298        };
299        let msg = err.to_string();
300        assert!(
301            msg.contains("method not allowed"),
302            "expected 'method not allowed' in '{msg}'"
303        );
304        assert!(msg.contains("DELETE"), "expected 'DELETE' in '{msg}'");
305        assert!(msg.contains("/bar"), "expected '/bar' in '{msg}'");
306    }
307
308    // -------------------------------------------------------------------------
309    // From conversion tests
310    // -------------------------------------------------------------------------
311
312    #[test]
313    fn test_from_invalid_uri() {
314        let raw: http::uri::InvalidUri = "not a valid uri!!!"
315            .parse::<http::Uri>()
316            .expect_err("should fail");
317        let result = OxiHttpError::from(raw);
318        assert!(
319            matches!(result, OxiHttpError::InvalidUri(_)),
320            "expected InvalidUri variant"
321        );
322    }
323
324    #[test]
325    fn test_from_http_error() {
326        let raw = http::Request::builder()
327            .header("\n", "x")
328            .body(())
329            .expect_err("should fail with invalid header name");
330        let result = OxiHttpError::from(raw);
331        assert!(
332            matches!(result, OxiHttpError::Http(_)),
333            "expected Http variant"
334        );
335    }
336
337    #[test]
338    fn test_from_io_error() {
339        let raw = std::io::Error::new(
340            std::io::ErrorKind::ConnectionRefused,
341            "test io error message",
342        );
343        let result = OxiHttpError::from(raw);
344        assert!(matches!(result, OxiHttpError::Io(_)), "expected Io variant");
345        assert!(
346            result.to_string().contains("test io error message"),
347            "Display should include the original io message"
348        );
349    }
350
351    // -------------------------------------------------------------------------
352    // status_code() tests
353    // -------------------------------------------------------------------------
354
355    #[test]
356    fn test_status_code_route_not_found() {
357        let err = OxiHttpError::RouteNotFound {
358            method: "GET".to_string(),
359            path: "/missing".to_string(),
360        };
361        assert_eq!(err.status_code(), Some(http::StatusCode::NOT_FOUND));
362    }
363
364    #[test]
365    fn test_status_code_method_not_allowed() {
366        let err = OxiHttpError::MethodNotAllowed {
367            method: "PUT".to_string(),
368            path: "/resource".to_string(),
369        };
370        assert_eq!(
371            err.status_code(),
372            Some(http::StatusCode::METHOD_NOT_ALLOWED)
373        );
374    }
375
376    #[test]
377    fn test_status_code_timeout() {
378        let err = OxiHttpError::Timeout("waited too long".to_string());
379        assert_eq!(err.status_code(), Some(http::StatusCode::REQUEST_TIMEOUT));
380    }
381
382    #[test]
383    fn test_status_code_body_is_none() {
384        let err = OxiHttpError::Body("incomplete body".to_string());
385        assert_eq!(err.status_code(), None);
386    }
387
388    // -------------------------------------------------------------------------
389    // Predicate tests
390    // -------------------------------------------------------------------------
391
392    #[test]
393    fn test_is_timeout_true() {
394        let err = OxiHttpError::Timeout("timed out".to_string());
395        assert!(err.is_timeout());
396    }
397
398    #[test]
399    fn test_is_timeout_false() {
400        let err = OxiHttpError::Body("body error".to_string());
401        assert!(!err.is_timeout());
402    }
403
404    #[test]
405    fn test_is_connect_dns() {
406        let err = OxiHttpError::Dns("nxdomain".to_string());
407        assert!(err.is_connect());
408    }
409
410    #[test]
411    fn test_is_connect_pool() {
412        let err = OxiHttpError::ConnectionPool("exhausted".to_string());
413        assert!(err.is_connect());
414    }
415
416    #[test]
417    fn test_is_connect_tls() {
418        let err = OxiHttpError::Tls("bad cert".to_string());
419        assert!(err.is_connect());
420    }
421
422    #[test]
423    fn test_is_connect_false() {
424        let err = OxiHttpError::Timeout("timed out".to_string());
425        assert!(!err.is_connect());
426    }
427
428    #[test]
429    fn test_is_body_true() {
430        let err = OxiHttpError::Body("truncated".to_string());
431        assert!(err.is_body());
432    }
433
434    #[test]
435    fn test_is_body_false() {
436        let err = OxiHttpError::Json("bad json".to_string());
437        assert!(!err.is_body());
438    }
439
440    #[test]
441    fn test_is_redirect_true() {
442        let err = OxiHttpError::Redirect("loop detected".to_string());
443        assert!(err.is_redirect());
444    }
445
446    #[test]
447    fn test_is_redirect_false() {
448        let err = OxiHttpError::Timeout("timed out".to_string());
449        assert!(!err.is_redirect());
450    }
451}