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("request was cancelled")]
43    Cancelled,
44
45    #[error("client base URL is required; call ClientBuilder::base_url")]
46    MissingBaseUrl,
47
48    #[error("retries exhausted after {attempts} attempts")]
49    RetryExhausted { attempts: u32, last: Option<String> },
50
51    /// Returned from [`on_request`](crate::hooks::Hooks::on_request) or
52    /// [`on_response`](crate::hooks::Hooks::on_response) to abort the pipeline.
53    /// Prefer constructing this with [`Error::hook`](Self::hook) rather than [`Error::Other`](Self::Other).
54    #[error("hook error: {0}")]
55    Hook(String),
56
57    #[error("{0}")]
58    Other(String),
59}
60
61impl Error {
62    pub fn http(status: StatusCode, message: impl Into<String>, body: Option<Bytes>) -> Self {
63        Self::http_with_status_text(
64            status,
65            status.canonical_reason().unwrap_or("").to_string(),
66            message,
67            body,
68        )
69    }
70
71    pub fn http_with_status_text(
72        status: StatusCode,
73        status_text: impl Into<String>,
74        message: impl Into<String>,
75        body: Option<Bytes>,
76    ) -> Self {
77        Self::Http {
78            status,
79            status_text: status_text.into(),
80            message: message.into(),
81            body,
82        }
83    }
84
85    pub fn status(&self) -> Option<StatusCode> {
86        match self {
87            Self::Http { status, .. } => Some(*status),
88            #[cfg(feature = "json")]
89            Self::Deserialize { status, .. } => Some(*status),
90            #[cfg(feature = "validate")]
91            Self::Validation { status, .. } => Some(*status),
92            _ => None,
93        }
94    }
95
96    pub fn status_text(&self) -> Option<&str> {
97        match self {
98            Self::Http { status_text, .. } => Some(status_text),
99            _ => None,
100        }
101    }
102
103    pub fn body(&self) -> Option<&Bytes> {
104        match self {
105            Self::Http { body, .. } => body.as_ref(),
106            #[cfg(feature = "json")]
107            Self::Deserialize { body, .. } => body.as_ref(),
108            #[cfg(feature = "validate")]
109            Self::Validation { body, .. } => body.as_ref(),
110            _ => None,
111        }
112    }
113
114    /// Returns `true` when transport retries were configured but all attempts failed.
115    pub fn is_retry_exhausted(&self) -> bool {
116        matches!(self, Self::RetryExhausted { .. })
117    }
118
119    /// Returns `true` when the request was cancelled via [`CancellationToken`](crate::CancellationToken).
120    pub fn is_cancelled(&self) -> bool {
121        matches!(self, Self::Cancelled)
122    }
123
124    /// Builds a hook failure for [`Hooks::on_request`](crate::hooks::Hooks::on_request) /
125    /// [`Hooks::on_response`](crate::hooks::Hooks::on_response).
126    pub fn hook(msg: impl Into<String>) -> Self {
127        Self::Hook(msg.into())
128    }
129
130    /// Returns `true` when the error is [`Error::Hook`](Self::Hook).
131    pub fn is_hook(&self) -> bool {
132        matches!(self, Self::Hook(_))
133    }
134
135    pub(crate) fn retry_exhausted(attempts: u32, last: Error) -> Self {
136        Self::RetryExhausted {
137            attempts,
138            last: Some(last.to_string()),
139        }
140    }
141
142    /// Parse the error response body as JSON (for API error payloads).
143    #[cfg(feature = "json")]
144    pub fn api_json<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
145        let body = self.body()?;
146        serde_json::from_slice(body).ok()
147    }
148
149    /// Parse and validate the error response body (feature `validate`).
150    #[cfg(feature = "validate")]
151    pub fn api_json_validated<T>(&self) -> Option<T>
152    where
153        T: serde::de::DeserializeOwned + garde::Validate,
154        T::Context: Default,
155    {
156        let body = self.body()?;
157        let value: T = serde_json::from_slice(body).ok()?;
158        value.validate().ok()?;
159        Some(value)
160    }
161}
162
163pub(crate) fn map_transport_error(err: reqwest::Error) -> Error {
164    if err.is_timeout() {
165        Error::Timeout
166    } else {
167        Error::Transport(err.to_string())
168    }
169}
170
171#[cfg(all(test, feature = "json"))]
172mod tests {
173    use super::*;
174    use serde::Deserialize;
175
176    #[derive(Debug, Deserialize, PartialEq)]
177    struct ApiError {
178        message: String,
179    }
180
181    #[test]
182    fn api_json_parses_http_body() {
183        let err = Error::http_with_status_text(
184            StatusCode::BAD_REQUEST,
185            "Bad Request",
186            "bad request",
187            Some(bytes::Bytes::from_static(br#"{"message":"invalid"}"#)),
188        );
189        let api: ApiError = err.api_json().unwrap();
190        assert_eq!(api.message, "invalid");
191    }
192
193    #[test]
194    fn status_and_status_text_accessors() {
195        let err = Error::http(StatusCode::NOT_FOUND, "not found", None);
196        assert_eq!(err.status(), Some(StatusCode::NOT_FOUND));
197        assert_eq!(err.status_text(), Some("Not Found"));
198    }
199
200    #[test]
201    fn api_json_returns_none_without_body() {
202        let err = Error::http(StatusCode::INTERNAL_SERVER_ERROR, "err", None);
203        assert!(err.api_json::<ApiError>().is_none());
204    }
205
206    #[test]
207    fn hook_constructor_and_is_hook() {
208        let err = Error::hook("blocked");
209        assert!(err.is_hook());
210        assert!(matches!(err, Error::Hook(msg) if msg == "blocked"));
211    }
212
213    #[test]
214    fn retry_exhausted_helper_sets_flag() {
215        let err = Error::retry_exhausted(3, Error::Timeout);
216        assert!(err.is_retry_exhausted());
217        assert!(matches!(
218            err,
219            Error::RetryExhausted {
220                attempts: 3,
221                last: Some(_)
222            }
223        ));
224    }
225}