Skip to main content

kithara_net/
error.rs

1use reqwest::{Error as ReqwestError, Url};
2use thiserror::Error;
3
4pub type NetResult<T> = Result<T, NetError>;
5
6/// Centralized error type for kithara-net
7#[derive(Debug, Error, Clone)]
8pub enum NetError {
9    #[error("HTTP request failed: {0}")]
10    Http(String),
11    #[error("Timeout")]
12    Timeout,
13    #[error("Request failed after {max_retries} retries: {source}")]
14    RetryExhausted { max_retries: u32, source: Box<Self> },
15    #[error("HTTP {status}: {body:?} for URL: {url:?}")]
16    HttpError {
17        status: u16,
18        url: Url,
19        body: Option<String>,
20    },
21    #[error("not implemented")]
22    Unimplemented,
23    #[error("Cancelled")]
24    Cancelled,
25    #[error("Invalid content-type: {0}")]
26    InvalidContentType(String),
27}
28
29impl NetError {
30    /// HTTP 408 Request Timeout.
31    const HTTP_REQUEST_TIMEOUT: u16 = 408;
32
33    /// Minimum HTTP status code for server errors (5xx).
34    const HTTP_SERVER_ERROR_MIN: u16 = 500;
35
36    /// HTTP 429 Too Many Requests.
37    const HTTP_TOO_MANY_REQUESTS: u16 = 429;
38
39    /// Checks if this error is considered retryable
40    #[must_use]
41    // ast-grep-ignore: idioms.match-self-conversion
42    pub fn is_retryable(&self) -> bool {
43        match self {
44            Self::Http(http_err_str) => {
45                http_err_str.contains("500")
46                    || http_err_str.contains("502")
47                    || http_err_str.contains("503")
48                    || http_err_str.contains("504")
49                    || http_err_str.contains("429")
50                    || http_err_str.contains("408")
51                    || http_err_str.contains("timeout")
52                    || http_err_str.contains("connection")
53                    || http_err_str.contains("network")
54                    || http_err_str.contains("decoding")
55                    || http_err_str.contains("body")
56            }
57            Self::Timeout => true,
58            Self::HttpError { status, .. } => {
59                *status >= Self::HTTP_SERVER_ERROR_MIN
60                    || *status == Self::HTTP_TOO_MANY_REQUESTS
61                    || *status == Self::HTTP_REQUEST_TIMEOUT
62            }
63            Self::RetryExhausted { .. }
64            | Self::Unimplemented
65            | Self::Cancelled
66            | Self::InvalidContentType(_) => false,
67        }
68    }
69
70    /// Creates a timeout error
71    #[must_use]
72    pub fn timeout() -> Self {
73        Self::Timeout
74    }
75}
76
77impl From<ReqwestError> for NetError {
78    fn from(e: ReqwestError) -> Self {
79        if e.is_timeout() {
80            return Self::Timeout;
81        }
82        let mut msg = e.to_string();
83        let mut current: &dyn std::error::Error = &e;
84        while let Some(source) = current.source() {
85            msg += &format!(": {source}");
86            current = source;
87        }
88        Self::Http(msg)
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    mod kithara {
95        pub(crate) use kithara_test_macros::test;
96    }
97
98    use super::*;
99
100    fn test_url(raw: &str) -> Url {
101        Url::parse(raw).expect("BUG: hard-coded test URL is valid")
102    }
103
104    #[kithara::test(tokio)]
105    #[case::timeout_error(NetError::timeout(), NetError::Timeout)]
106    async fn test_error_creation_methods(
107        #[case] created_error: NetError,
108        #[case] expected_error: NetError,
109    ) {
110        match (created_error, expected_error) {
111            (NetError::Timeout, NetError::Timeout) => (),
112            _ => panic!("Errors don't match"),
113        }
114    }
115
116    #[kithara::test(tokio)]
117    #[case::timeout(NetError::Timeout, true)]
118    #[case::http_500(NetError::HttpError { status: 500, url: test_url("http://example.com"), body: None }, true)]
119    #[case::http_429(NetError::HttpError { status: 429, url: test_url("http://example.com"), body: None }, true)]
120    #[case::http_404(NetError::HttpError { status: 404, url: test_url("http://example.com"), body: None }, false)]
121    #[case::unimplemented(NetError::Unimplemented, false)]
122    #[case::retry_exhausted(NetError::RetryExhausted { max_retries: 3, source: Box::new(NetError::Timeout) }, false)]
123    #[case::invalid_content_type(NetError::InvalidContentType("text/html".to_string()), false)]
124    async fn test_is_retryable(#[case] error: NetError, #[case] expected_retryable: bool) {
125        assert_eq!(error.is_retryable(), expected_retryable);
126    }
127
128    #[kithara::test(tokio)]
129    #[case::http_error(
130        NetError::Http("connection failed".to_string()),
131        "HTTP request failed: connection failed"
132    )]
133    #[case::timeout(NetError::Timeout, "Timeout")]
134    #[case::unimplemented(NetError::Unimplemented, "not implemented")]
135    #[case::http_error_with_details(
136        NetError::HttpError { status: 404, url: test_url("http://example.com/test"), body: Some("Not found".to_string()) },
137        "HTTP 404: Some(\"Not found\") for URL: Url { scheme: \"http\", cannot_be_a_base: false, username: \"\", password: None, host: Some(Domain(\"example.com\")), port: None, path: \"/test\", query: None, fragment: None }"
138    )]
139    async fn test_error_display(#[case] error: NetError, #[case] expected_prefix: &str) {
140        let display = error.to_string();
141        assert!(
142            display.starts_with(expected_prefix),
143            "Expected display to start with '{}', got '{}'",
144            expected_prefix,
145            display
146        );
147    }
148
149    #[kithara::test(tokio)]
150    async fn test_retry_exhausted_display() {
151        let source = Box::new(NetError::Timeout);
152        let error = NetError::RetryExhausted {
153            source,
154            max_retries: 3,
155        };
156
157        let display = error.to_string();
158        assert!(display.contains("Request failed after 3 retries: Timeout"));
159    }
160
161    #[kithara::test(tokio)]
162    #[case::timeout(NetError::Timeout)]
163    #[case::http_error(NetError::HttpError { status: 500, url: test_url("http://example.com"), body: None })]
164    #[case::unimplemented(NetError::Unimplemented)]
165    #[case::retry_exhausted(NetError::RetryExhausted { max_retries: 3, source: Box::new(NetError::Timeout) })]
166    async fn test_error_cloning(#[case] error: NetError) {
167        let cloned = error.clone();
168
169        assert_eq!(error.to_string(), cloned.to_string());
170
171        assert_eq!(error.is_retryable(), cloned.is_retryable());
172    }
173
174    #[kithara::test(tokio)]
175    #[case::timeout(NetError::Timeout)]
176    #[case::http_error(NetError::HttpError { status: 404, url: test_url("http://example.com"), body: None })]
177    async fn test_error_debug(#[case] error: NetError) {
178        let debug_output = format!("{:?}", error);
179
180        match error {
181            NetError::Timeout => assert!(debug_output.contains("Timeout")),
182            NetError::HttpError { .. } => assert!(debug_output.contains("HttpError")),
183            _ => (),
184        }
185    }
186
187    #[kithara::test(tokio)]
188    async fn test_net_result_type() {
189        let ok_result: NetResult<i32> = Ok(42);
190        assert!(ok_result.is_ok());
191        assert!(matches!(ok_result, Ok(42)));
192
193        let err_result: NetResult<i32> = Err(NetError::Timeout);
194        assert!(err_result.is_err());
195
196        match err_result {
197            Err(NetError::Timeout) => (),
198            _ => panic!("Expected Timeout error"),
199        }
200    }
201
202    #[kithara::test(tokio)]
203    #[case("500 Internal Server Error", true)]
204    #[case("502 Bad Gateway", true)]
205    #[case("503 Service Unavailable", true)]
206    #[case("504 Gateway Timeout", true)]
207    #[case("429 Too Many Requests", true)]
208    #[case("408 Request Timeout", true)]
209    #[case("timeout while connecting", true)]
210    #[case("network error", true)]
211    #[case("connection reset", true)]
212    #[case("404 Not Found", false)]
213    #[case("400 Bad Request", false)]
214    #[case("403 Forbidden", false)]
215    #[case("401 Unauthorized", false)]
216    async fn test_http_error_string_parsing(
217        #[case] error_string: &str,
218        #[case] expected_retryable: bool,
219    ) {
220        let error = NetError::Http(error_string.to_string());
221        assert_eq!(error.is_retryable(), expected_retryable);
222    }
223
224    #[kithara::test(tokio)]
225    async fn test_error_equality() {
226        let timeout1 = NetError::Timeout;
227        let timeout2 = NetError::Timeout;
228        assert_eq!(timeout1.to_string(), timeout2.to_string());
229
230        let http1 = NetError::Http("test".to_string());
231        let http2 = NetError::Http("test".to_string());
232        assert_eq!(http1.to_string(), http2.to_string());
233
234        let http3 = NetError::Http("error1".to_string());
235        let http4 = NetError::Http("error2".to_string());
236        assert_ne!(http3.to_string(), http4.to_string());
237    }
238}