1use reqwest::{Error as ReqwestError, Url};
2use thiserror::Error;
3
4pub type NetResult<T> = Result<T, NetError>;
5
6#[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 const HTTP_REQUEST_TIMEOUT: u16 = 408;
32
33 const HTTP_SERVER_ERROR_MIN: u16 = 500;
35
36 const HTTP_TOO_MANY_REQUESTS: u16 = 429;
38
39 #[must_use]
41 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 #[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}