Skip to main content

better_fetch/
error.rs

1//! Error types and helpers for HTTP, transport, hooks, and retries.
2//!
3//! Most operations return [`crate::Result`]. Use [`Error::status`] and [`Error::body`] on HTTP
4//! failures, and [`Error::api_json`] to parse structured API error payloads.
5
6use bytes::Bytes;
7use http::StatusCode;
8use thiserror::Error;
9
10/// Error type for better-fetch operations.
11#[derive(Debug, Error, Clone)]
12#[must_use = "errors must be handled or propagated with `?`"]
13pub enum Error {
14    /// Base URL parsing failed ([`ClientBuilder::base_url`](crate::ClientBuilder::base_url)).
15    #[error("invalid base URL: {0}")]
16    InvalidBaseUrl(#[from] url::ParseError),
17
18    /// Underlying transport failure (connection, DNS, etc.).
19    #[error("transport error: {0}")]
20    Transport(String),
21
22    /// Non-success HTTP response (when using throw mode or `send_json`).
23    #[error("HTTP {status} {status_text}: {message}")]
24    Http {
25        /// HTTP status code.
26        status: StatusCode,
27        /// Canonical reason phrase.
28        status_text: String,
29        /// Human-readable message.
30        message: String,
31        /// Response body when buffered.
32        body: Option<Bytes>,
33    },
34
35    /// JSON response could not be deserialized (feature `json`).
36    #[cfg(feature = "json")]
37    #[error("failed to deserialize response body: {message}")]
38    Deserialize {
39        status: StatusCode,
40        message: String,
41        body: Option<Bytes>,
42    },
43
44    /// Response failed garde validation (feature `validate`).
45    #[cfg(feature = "validate")]
46    #[error("response validation failed: {message}")]
47    Validation {
48        status: StatusCode,
49        message: String,
50        body: Option<Bytes>,
51    },
52
53    /// Request exceeded the configured timeout.
54    #[error("request timed out")]
55    Timeout,
56
57    /// Request was cancelled via [`CancellationToken`](crate::CancellationToken).
58    #[error("request was cancelled")]
59    Cancelled,
60
61    /// [`ClientBuilder::build`](crate::ClientBuilder::build) without [`ClientBuilder::base_url`](crate::ClientBuilder::base_url).
62    #[error("client base URL is required; call ClientBuilder::base_url")]
63    MissingBaseUrl,
64
65    /// Transport retries were exhausted.
66    #[error("retries exhausted after {attempts} attempts")]
67    RetryExhausted {
68        /// Total attempts made (initial + retries).
69        attempts: u32,
70        /// Stringified last error, when available.
71        last: Option<String>,
72    },
73
74    /// Returned from [`on_request`](crate::hooks::Hooks::on_request) or
75    /// [`on_response`](crate::hooks::Hooks::on_response) to abort the pipeline.
76    /// Prefer constructing this with [`Error::hook`](Self::hook) rather than [`Error::Other`](Self::Other).
77    #[error("hook error: {0}")]
78    Hook(String),
79
80    /// Catch-all for configuration or plugin errors.
81    #[error("{0}")]
82    Other(String),
83}
84
85impl Error {
86    /// Builds an HTTP error with canonical status text.
87    pub fn http(status: StatusCode, message: impl Into<String>, body: Option<Bytes>) -> Self {
88        Self::http_with_status_text(
89            status,
90            status.canonical_reason().unwrap_or("").to_string(),
91            message,
92            body,
93        )
94    }
95
96    /// Builds an HTTP error with explicit status text.
97    pub fn http_with_status_text(
98        status: StatusCode,
99        status_text: impl Into<String>,
100        message: impl Into<String>,
101        body: Option<Bytes>,
102    ) -> Self {
103        Self::Http {
104            status,
105            status_text: status_text.into(),
106            message: message.into(),
107            body,
108        }
109    }
110
111    /// Returns the HTTP status when this error is response-related.
112    pub fn status(&self) -> Option<StatusCode> {
113        match self {
114            Self::Http { status, .. } => Some(*status),
115            #[cfg(feature = "json")]
116            Self::Deserialize { status, .. } => Some(*status),
117            #[cfg(feature = "validate")]
118            Self::Validation { status, .. } => Some(*status),
119            _ => None,
120        }
121    }
122
123    /// Returns the canonical status text for [`Error::Http`].
124    pub fn status_text(&self) -> Option<&str> {
125        match self {
126            Self::Http { status_text, .. } => Some(status_text),
127            _ => None,
128        }
129    }
130
131    /// Returns the response body when present on HTTP, deserialize, or validation errors.
132    pub fn body(&self) -> Option<&Bytes> {
133        match self {
134            Self::Http { body, .. } => body.as_ref(),
135            #[cfg(feature = "json")]
136            Self::Deserialize { body, .. } => body.as_ref(),
137            #[cfg(feature = "validate")]
138            Self::Validation { body, .. } => body.as_ref(),
139            _ => None,
140        }
141    }
142
143    /// Returns `true` when transport retries were configured but all attempts failed.
144    pub fn is_retry_exhausted(&self) -> bool {
145        matches!(self, Self::RetryExhausted { .. })
146    }
147
148    /// Returns `true` when the request was cancelled via [`CancellationToken`](crate::CancellationToken).
149    pub fn is_cancelled(&self) -> bool {
150        matches!(self, Self::Cancelled)
151    }
152
153    /// Builds a hook failure for [`Hooks::on_request`](crate::hooks::Hooks::on_request) /
154    /// [`Hooks::on_response`](crate::hooks::Hooks::on_response).
155    pub fn hook(msg: impl Into<String>) -> Self {
156        Self::Hook(msg.into())
157    }
158
159    /// Returns `true` when the error is [`Error::Hook`](Self::Hook).
160    pub fn is_hook(&self) -> bool {
161        matches!(self, Self::Hook(_))
162    }
163
164    pub(crate) fn retry_exhausted(attempts: u32, last: Error) -> Self {
165        Self::RetryExhausted {
166            attempts,
167            last: Some(last.to_string()),
168        }
169    }
170
171    /// Parses the error response body as JSON (for API error payloads).
172    ///
173    /// # Examples
174    ///
175    /// ```
176    /// use better_fetch::Error;
177    /// use http::StatusCode;
178    /// use serde::Deserialize;
179    ///
180    /// #[derive(Debug, Deserialize, PartialEq)]
181    /// struct ApiError {
182    ///     message: String,
183    /// }
184    ///
185    /// let err = Error::http_with_status_text(
186    ///     StatusCode::BAD_REQUEST,
187    ///     "Bad Request",
188    ///     "bad request",
189    ///     Some(bytes::Bytes::from_static(br#"{"message":"invalid"}"#)),
190    /// );
191    /// let api: ApiError = err.api_json().unwrap();
192    /// assert_eq!(api.message, "invalid");
193    /// ```
194    #[cfg(feature = "json")]
195    pub fn api_json<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
196        let body = self.body()?;
197        serde_json::from_slice(body).ok()
198    }
199
200    /// Parses and validates the error response body (feature `validate`).
201    #[cfg(feature = "validate")]
202    pub fn api_json_validated<T>(&self) -> Option<T>
203    where
204        T: serde::de::DeserializeOwned + garde::Validate,
205        T::Context: Default,
206    {
207        let body = self.body()?;
208        let value: T = serde_json::from_slice(body).ok()?;
209        value.validate().ok()?;
210        Some(value)
211    }
212}
213
214pub(crate) fn map_transport_error(err: reqwest::Error) -> Error {
215    if err.is_timeout() {
216        Error::Timeout
217    } else {
218        Error::Transport(err.to_string())
219    }
220}
221
222#[cfg(all(test, feature = "json"))]
223mod tests {
224    use super::*;
225    use serde::Deserialize;
226
227    #[derive(Debug, Deserialize, PartialEq)]
228    struct ApiError {
229        message: String,
230    }
231
232    #[test]
233    fn api_json_parses_http_body() {
234        let err = Error::http_with_status_text(
235            StatusCode::BAD_REQUEST,
236            "Bad Request",
237            "bad request",
238            Some(bytes::Bytes::from_static(br#"{"message":"invalid"}"#)),
239        );
240        let api: ApiError = err.api_json().unwrap();
241        assert_eq!(api.message, "invalid");
242    }
243
244    #[test]
245    fn status_and_status_text_accessors() {
246        let err = Error::http(StatusCode::NOT_FOUND, "not found", None);
247        assert_eq!(err.status(), Some(StatusCode::NOT_FOUND));
248        assert_eq!(err.status_text(), Some("Not Found"));
249    }
250
251    #[test]
252    fn api_json_returns_none_without_body() {
253        let err = Error::http(StatusCode::INTERNAL_SERVER_ERROR, "err", None);
254        assert!(err.api_json::<ApiError>().is_none());
255    }
256
257    #[test]
258    fn hook_constructor_and_is_hook() {
259        let err = Error::hook("blocked");
260        assert!(err.is_hook());
261        assert!(matches!(err, Error::Hook(msg) if msg == "blocked"));
262    }
263
264    #[test]
265    fn retry_exhausted_helper_sets_flag() {
266        let err = Error::retry_exhausted(3, Error::Timeout);
267        assert!(err.is_retry_exhausted());
268        assert!(matches!(
269            err,
270            Error::RetryExhausted {
271                attempts: 3,
272                last: Some(_)
273            }
274        ));
275    }
276}