1#[cfg(not(all(feature = "client-apple", any(target_os = "macos", target_os = "ios"))))]
2use std::fmt::Write;
3use std::num::NonZeroU16;
4
5use thiserror::Error;
6use url::Url;
7
8#[cfg(not(all(feature = "client-apple", any(target_os = "macos", target_os = "ios"))))]
9use crate::backend::BackendError as ReqwestError;
10
11pub type NetResult<T> = Result<T, NetError>;
12
13#[non_exhaustive]
15#[derive(Debug, Error, Clone)]
16pub enum NetError {
17 #[error("HTTP {status}: {body:?} for URL: {url:?}")]
18 Status {
19 status: NonZeroU16,
20 url: Option<Url>,
21 body: Option<String>,
22 },
23 #[error("Timeout")]
24 Timeout,
25 #[error("Network error: {0}")]
26 Network(String),
27 #[error("Decode error: {0}")]
28 Decode(String),
29 #[error("Request failed after {max_retries} retries: {source}")]
30 RetryExhausted { max_retries: u32, source: Box<Self> },
31 #[error("not implemented")]
32 Unimplemented,
33 #[error("Cancelled")]
34 Cancelled,
35 #[error("Invalid content-type: {0}")]
36 InvalidContentType(String),
37}
38
39#[non_exhaustive]
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Retryability {
44 Transient,
45 Fatal,
46}
47
48impl NetError {
49 const HTTP_REQUEST_TIMEOUT: u16 = 408;
51
52 const HTTP_SERVER_ERROR_MIN: u16 = 500;
54
55 const HTTP_TOO_MANY_REQUESTS: u16 = 429;
57
58 #[must_use]
60 pub fn retryability(&self) -> Retryability {
61 self.into()
62 }
63
64 #[must_use]
66 pub fn timeout() -> Self {
67 Self::Timeout
68 }
69}
70
71impl From<&NetError> for Retryability {
72 fn from(error: &NetError) -> Self {
73 match error {
74 NetError::Status { status, .. } => {
75 let code = status.get();
76 if code >= NetError::HTTP_SERVER_ERROR_MIN
77 || code == NetError::HTTP_TOO_MANY_REQUESTS
78 || code == NetError::HTTP_REQUEST_TIMEOUT
79 {
80 Self::Transient
81 } else {
82 Self::Fatal
83 }
84 }
85 NetError::Timeout | NetError::Network(_) => Self::Transient,
86 NetError::Decode(_)
87 | NetError::RetryExhausted { .. }
88 | NetError::Unimplemented
89 | NetError::Cancelled
90 | NetError::InvalidContentType(_) => Self::Fatal,
91 }
92 }
93}
94
95#[cfg(not(all(feature = "client-apple", any(target_os = "macos", target_os = "ios"))))]
96impl From<ReqwestError> for NetError {
97 fn from(e: ReqwestError) -> Self {
98 if e.is_timeout() {
99 return Self::Timeout;
100 }
101 e.status()
103 .and_then(|s| NonZeroU16::new(s.as_u16()))
104 .map_or_else(
105 || Self::Network(error_chain(&e)),
106 |status| Self::Status {
107 status,
108 url: e.url().cloned(),
109 body: None,
110 },
111 )
112 }
113}
114
115#[cfg(not(all(feature = "client-apple", any(target_os = "macos", target_os = "ios"))))]
116fn error_chain(e: &ReqwestError) -> String {
117 let mut msg = e.to_string();
118 let mut current: &dyn std::error::Error = e;
119 while let Some(source) = current.source() {
120 let _ = write!(msg, ": {source}");
121 current = source;
122 }
123 msg
124}
125
126#[cfg(test)]
127mod tests {
128 mod kithara {
129 pub(crate) use kithara_test_macros::test;
130 }
131
132 use super::*;
133
134 fn test_url(raw: &str) -> Url {
135 Url::parse(raw).expect("BUG: hard-coded test URL is valid")
136 }
137
138 fn nz(status: u16) -> NonZeroU16 {
139 NonZeroU16::new(status).expect("BUG: hard-coded test status is non-zero")
140 }
141
142 #[kithara::test(tokio)]
143 #[case::timeout_error(NetError::timeout(), NetError::Timeout)]
144 async fn test_error_creation_methods(
145 #[case] created_error: NetError,
146 #[case] expected_error: NetError,
147 ) {
148 match (created_error, expected_error) {
149 (NetError::Timeout, NetError::Timeout) => (),
150 _ => panic!("Errors don't match"),
151 }
152 }
153
154 #[kithara::test(tokio)]
155 #[case::timeout(NetError::Timeout, Retryability::Transient)]
156 #[case::network(NetError::Network("connection reset".to_string()), Retryability::Transient)]
157 #[case::status_500(NetError::Status { status: nz(500), url: Some(test_url("http://example.com")), body: None }, Retryability::Transient)]
158 #[case::status_429(NetError::Status { status: nz(429), url: Some(test_url("http://example.com")), body: None }, Retryability::Transient)]
159 #[case::status_408(NetError::Status { status: nz(408), url: Some(test_url("http://example.com")), body: None }, Retryability::Transient)]
160 #[case::status_404(NetError::Status { status: nz(404), url: Some(test_url("http://example.com")), body: None }, Retryability::Fatal)]
161 #[case::decode(NetError::Decode("invalid body".to_string()), Retryability::Fatal)]
162 #[case::unimplemented(NetError::Unimplemented, Retryability::Fatal)]
163 #[case::retry_exhausted(NetError::RetryExhausted { max_retries: 3, source: Box::new(NetError::Timeout) }, Retryability::Fatal)]
164 #[case::invalid_content_type(NetError::InvalidContentType("text/html".to_string()), Retryability::Fatal)]
165 async fn test_retryability(#[case] error: NetError, #[case] expected: Retryability) {
166 assert_eq!(error.retryability(), expected);
167 }
168
169 #[kithara::test(tokio)]
170 #[case::timeout(NetError::Timeout, "Timeout")]
171 #[case::unimplemented(NetError::Unimplemented, "not implemented")]
172 #[case::network(NetError::Network("dns failure".to_string()), "Network error: dns failure")]
173 #[case::status_with_details(
174 NetError::Status { status: nz(404), url: Some(test_url("http://example.com/test")), body: Some("Not found".to_string()) },
175 "HTTP 404: Some(\"Not found\") for URL: Some("
176 )]
177 async fn test_error_display(#[case] error: NetError, #[case] expected_prefix: &str) {
178 let display = error.to_string();
179 assert!(
180 display.starts_with(expected_prefix),
181 "Expected display to start with '{}', got '{}'",
182 expected_prefix,
183 display
184 );
185 }
186
187 #[kithara::test(tokio)]
188 async fn test_retry_exhausted_display() {
189 let source = Box::new(NetError::Timeout);
190 let error = NetError::RetryExhausted {
191 source,
192 max_retries: 3,
193 };
194
195 let display = error.to_string();
196 assert!(display.contains("Request failed after 3 retries: Timeout"));
197 }
198
199 #[kithara::test(tokio)]
200 #[case::timeout(NetError::Timeout)]
201 #[case::status(NetError::Status { status: nz(500), url: Some(test_url("http://example.com")), body: None })]
202 #[case::network(NetError::Network("reset".to_string()))]
203 #[case::unimplemented(NetError::Unimplemented)]
204 #[case::retry_exhausted(NetError::RetryExhausted { max_retries: 3, source: Box::new(NetError::Timeout) })]
205 async fn test_error_cloning(#[case] error: NetError) {
206 let cloned = error.clone();
207
208 assert_eq!(error.to_string(), cloned.to_string());
209
210 assert_eq!(error.retryability(), cloned.retryability());
211 }
212
213 #[kithara::test(tokio)]
214 #[case::timeout(NetError::Timeout)]
215 #[case::status(NetError::Status { status: nz(404), url: Some(test_url("http://example.com")), body: None })]
216 async fn test_error_debug(#[case] error: NetError) {
217 let debug_output = format!("{:?}", error);
218
219 match error {
220 NetError::Timeout => assert!(debug_output.contains("Timeout")),
221 NetError::Status { .. } => assert!(debug_output.contains("Status")),
222 _ => (),
223 }
224 }
225
226 #[kithara::test(tokio)]
227 async fn test_net_result_type() {
228 let ok_result: NetResult<i32> = Ok(42);
229 assert!(ok_result.is_ok());
230 assert!(matches!(ok_result, Ok(42)));
231
232 let err_result: NetResult<i32> = Err(NetError::Timeout);
233 assert!(err_result.is_err());
234
235 match err_result {
236 Err(NetError::Timeout) => (),
237 _ => panic!("Expected Timeout error"),
238 }
239 }
240}