Skip to main content

better_fetch/
error.rs

1use bytes::Bytes;
2use http::StatusCode;
3use thiserror::Error;
4
5/// Error type for better-fetch operations.
6#[derive(Debug, Error, Clone)]
7#[must_use = "errors must be handled or propagated with `?`"]
8pub enum Error {
9    #[error("invalid base URL: {0}")]
10    InvalidBaseUrl(#[from] url::ParseError),
11
12    #[error("transport error: {0}")]
13    Transport(String),
14
15    #[error("HTTP {status} {status_text}: {message}")]
16    Http {
17        status: StatusCode,
18        status_text: String,
19        message: String,
20        body: Option<Bytes>,
21    },
22
23    #[cfg(feature = "json")]
24    #[error("failed to deserialize response body: {message}")]
25    Deserialize {
26        status: StatusCode,
27        message: String,
28        body: Option<Bytes>,
29    },
30
31    #[cfg(feature = "validate")]
32    #[error("response validation failed: {message}")]
33    Validation {
34        status: StatusCode,
35        message: String,
36        body: Option<Bytes>,
37    },
38
39    #[error("request timed out")]
40    Timeout,
41
42    #[error("retries exhausted after {attempts} attempts")]
43    RetryExhausted { attempts: u32, last: Option<String> },
44
45    #[error("hook error: {0}")]
46    Hook(String),
47
48    #[error("{0}")]
49    Other(String),
50}
51
52impl Error {
53    pub fn http(status: StatusCode, message: impl Into<String>, body: Option<Bytes>) -> Self {
54        Self::http_with_status_text(
55            status,
56            status.canonical_reason().unwrap_or("").to_string(),
57            message,
58            body,
59        )
60    }
61
62    pub fn http_with_status_text(
63        status: StatusCode,
64        status_text: impl Into<String>,
65        message: impl Into<String>,
66        body: Option<Bytes>,
67    ) -> Self {
68        Self::Http {
69            status,
70            status_text: status_text.into(),
71            message: message.into(),
72            body,
73        }
74    }
75
76    pub fn status(&self) -> Option<StatusCode> {
77        match self {
78            Self::Http { status, .. } => Some(*status),
79            #[cfg(feature = "json")]
80            Self::Deserialize { status, .. } => Some(*status),
81            #[cfg(feature = "validate")]
82            Self::Validation { status, .. } => Some(*status),
83            _ => None,
84        }
85    }
86
87    pub fn status_text(&self) -> Option<&str> {
88        match self {
89            Self::Http { status_text, .. } => Some(status_text),
90            _ => None,
91        }
92    }
93
94    pub fn body(&self) -> Option<&Bytes> {
95        match self {
96            Self::Http { body, .. } => body.as_ref(),
97            #[cfg(feature = "json")]
98            Self::Deserialize { body, .. } => body.as_ref(),
99            #[cfg(feature = "validate")]
100            Self::Validation { body, .. } => body.as_ref(),
101            _ => None,
102        }
103    }
104
105    /// Returns `true` when transport retries were configured but all attempts failed.
106    pub fn is_retry_exhausted(&self) -> bool {
107        matches!(self, Self::RetryExhausted { .. })
108    }
109
110    pub(crate) fn retry_exhausted(attempts: u32, last: Error) -> Self {
111        Self::RetryExhausted {
112            attempts,
113            last: Some(last.to_string()),
114        }
115    }
116
117    /// Parse the error response body as JSON (for API error payloads).
118    #[cfg(feature = "json")]
119    pub fn api_json<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
120        let body = self.body()?;
121        serde_json::from_slice(body).ok()
122    }
123
124    /// Parse and validate the error response body (feature `validate`).
125    #[cfg(feature = "validate")]
126    pub fn api_json_validated<T>(&self) -> Option<T>
127    where
128        T: serde::de::DeserializeOwned + garde::Validate,
129        T::Context: Default,
130    {
131        let body = self.body()?;
132        let value: T = serde_json::from_slice(body).ok()?;
133        value.validate().ok()?;
134        Some(value)
135    }
136}
137
138pub(crate) fn map_transport_error(err: reqwest::Error) -> Error {
139    if err.is_timeout() {
140        Error::Timeout
141    } else {
142        Error::Transport(err.to_string())
143    }
144}
145
146#[cfg(all(test, feature = "json"))]
147mod tests {
148    use super::*;
149    use serde::Deserialize;
150
151    #[derive(Debug, Deserialize, PartialEq)]
152    struct ApiError {
153        message: String,
154    }
155
156    #[test]
157    fn api_json_parses_http_body() {
158        let err = Error::http_with_status_text(
159            StatusCode::BAD_REQUEST,
160            "Bad Request",
161            "bad request",
162            Some(bytes::Bytes::from_static(br#"{"message":"invalid"}"#)),
163        );
164        let api: ApiError = err.api_json().unwrap();
165        assert_eq!(api.message, "invalid");
166    }
167
168    #[test]
169    fn status_and_status_text_accessors() {
170        let err = Error::http(StatusCode::NOT_FOUND, "not found", None);
171        assert_eq!(err.status(), Some(StatusCode::NOT_FOUND));
172        assert_eq!(err.status_text(), Some("Not Found"));
173    }
174
175    #[test]
176    fn api_json_returns_none_without_body() {
177        let err = Error::http(StatusCode::INTERNAL_SERVER_ERROR, "err", None);
178        assert!(err.api_json::<ApiError>().is_none());
179    }
180
181    #[test]
182    fn retry_exhausted_helper_sets_flag() {
183        let err = Error::retry_exhausted(3, Error::Timeout);
184        assert!(err.is_retry_exhausted());
185        assert!(matches!(
186            err,
187            Error::RetryExhausted {
188                attempts: 3,
189                last: Some(_)
190            }
191        ));
192    }
193}