Skip to main content

kithara_net/
error.rs

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/// Centralized error type for kithara-net.
14#[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/// Whether a failed request is worth retrying. Decided from the typed
40/// [`NetError`] discriminant, never from substring matching on the message.
41#[non_exhaustive]
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Retryability {
44    Transient,
45    Fatal,
46}
47
48impl NetError {
49    /// HTTP 408 Request Timeout.
50    const HTTP_REQUEST_TIMEOUT: u16 = 408;
51
52    /// Minimum HTTP status code for server errors (5xx).
53    const HTTP_SERVER_ERROR_MIN: u16 = 500;
54
55    /// HTTP 429 Too Many Requests.
56    const HTTP_TOO_MANY_REQUESTS: u16 = 429;
57
58    /// Classifies the error for retry decisioning via its typed variant.
59    #[must_use]
60    pub fn retryability(&self) -> Retryability {
61        self.into()
62    }
63
64    /// Creates a timeout error.
65    #[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        // WHY: non-status reqwest errors (connect, body-EOF, decode) stay retryable so an early stream close can resume; fatal Decode is for a local sink write (dl/response.rs).
102        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}