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, [`Error::transport_kind`] on transport failures, and [`Error::api_json`] to parse
5//! structured API error payloads.
6
7use std::fmt;
8
9use bytes::Bytes;
10use http::StatusCode;
11use thiserror::Error;
12
13/// Classification of underlying transport failures (connection, body, decode, etc.).
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum TransportKind {
16    /// Connection failed (TCP/TLS/DNS and similar).
17    Connect,
18    /// Request or response body error.
19    Body,
20    /// Response body decoding error (e.g. decompression).
21    Decode,
22    /// Redirect policy violation.
23    Redirect,
24    /// Error building or sending the request.
25    Request,
26    /// Invalid request configuration.
27    Builder,
28    /// Protocol upgrade failure.
29    Upgrade,
30    /// Unclassified transport failure.
31    Other,
32}
33
34impl fmt::Display for TransportKind {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            Self::Connect => write!(f, "connect"),
38            Self::Body => write!(f, "body"),
39            Self::Decode => write!(f, "decode"),
40            Self::Redirect => write!(f, "redirect"),
41            Self::Request => write!(f, "request"),
42            Self::Builder => write!(f, "builder"),
43            Self::Upgrade => write!(f, "upgrade"),
44            Self::Other => write!(f, "other"),
45        }
46    }
47}
48
49/// Error type for better-fetch operations.
50#[derive(Debug, Error, Clone)]
51#[must_use = "errors must be handled or propagated with `?`"]
52pub enum Error {
53    /// Base URL parsing failed ([`ClientBuilder::base_url`](crate::ClientBuilder::base_url)).
54    #[error("invalid base URL: {0}")]
55    InvalidBaseUrl(#[from] url::ParseError),
56
57    /// Underlying transport failure (connection, DNS, body read, etc.).
58    #[error("transport error ({kind}): {message}")]
59    Transport {
60        /// Coarse category aligned with reqwest's `is_*` helpers.
61        kind: TransportKind,
62        /// Human-readable detail (typically from the underlying error's `Display`).
63        message: String,
64    },
65
66    /// Non-success HTTP response (when using throw mode or `send_json`).
67    #[error("HTTP {status} {status_text}: {message}")]
68    Http {
69        /// HTTP status code.
70        status: StatusCode,
71        /// Canonical reason phrase.
72        status_text: String,
73        /// Human-readable message.
74        message: String,
75        /// Response body when buffered.
76        body: Option<Bytes>,
77    },
78
79    /// JSON response could not be deserialized (feature `json`).
80    #[cfg(feature = "json")]
81    #[error("failed to deserialize response body: {message}")]
82    Deserialize {
83        status: StatusCode,
84        message: String,
85        body: Option<Bytes>,
86    },
87
88    /// Response failed garde validation (feature `validate`).
89    #[cfg(feature = "validate")]
90    #[error("response validation failed: {message}")]
91    Validation {
92        status: StatusCode,
93        message: String,
94        body: Option<Bytes>,
95    },
96
97    /// Request exceeded the configured timeout.
98    #[error("request timed out")]
99    Timeout,
100
101    /// Request was cancelled via [`CancellationToken`](crate::CancellationToken).
102    #[error("request was cancelled")]
103    Cancelled,
104
105    /// Response body exceeded [`ClientBuilder::max_response_bytes`](crate::ClientBuilder::max_response_bytes)
106    /// or a per-request [`RequestBuilder::max_response_bytes`](crate::RequestBuilder::max_response_bytes) limit.
107    #[error("response body exceeded limit of {limit} bytes")]
108    BodyTooLarge {
109        /// Configured maximum response size in bytes.
110        limit: u64,
111    },
112
113    /// [`ClientBuilder::build`](crate::ClientBuilder::build) without [`ClientBuilder::base_url`](crate::ClientBuilder::base_url).
114    #[error("client base URL is required; call ClientBuilder::base_url")]
115    MissingBaseUrl,
116
117    /// Transport retries were exhausted.
118    #[error("retries exhausted after {attempts} attempts")]
119    RetryExhausted {
120        /// Total attempts made (initial + retries).
121        attempts: u32,
122        /// Last error before retries were exhausted, when available.
123        last: Option<Box<Error>>,
124    },
125
126    /// Returned from [`on_request`](crate::hooks::Hooks::on_request) or
127    /// [`on_response`](crate::hooks::Hooks::on_response) to abort the pipeline.
128    /// Prefer constructing this with [`Error::hook`](Self::hook) rather than [`Error::Other`](Self::Other).
129    #[error("hook error: {0}")]
130    Hook(String),
131
132    /// Catch-all for configuration or plugin errors.
133    #[error("{0}")]
134    Other(String),
135}
136
137impl Error {
138    /// Builds a transport error with an explicit [`TransportKind`].
139    pub fn transport(kind: TransportKind, message: impl Into<String>) -> Self {
140        Self::Transport {
141            kind,
142            message: message.into(),
143        }
144    }
145
146    /// Builds a transport error with [`TransportKind::Other`].
147    pub fn transport_message(message: impl Into<String>) -> Self {
148        Self::transport(TransportKind::Other, message)
149    }
150
151    /// Returns the transport failure category when this error is [`Error::Transport`].
152    pub fn transport_kind(&self) -> Option<TransportKind> {
153        match self {
154            Self::Transport { kind, .. } => Some(*kind),
155            _ => None,
156        }
157    }
158
159    /// Returns the transport error detail string when this error is [`Error::Transport`].
160    pub fn transport_detail(&self) -> Option<&str> {
161        match self {
162            Self::Transport { message, .. } => Some(message),
163            _ => None,
164        }
165    }
166
167    /// Returns `true` when this error is [`Error::Transport`].
168    pub fn is_transport(&self) -> bool {
169        matches!(self, Self::Transport { .. })
170    }
171
172    /// Returns `true` when this error is [`Error::Timeout`].
173    pub fn is_timeout(&self) -> bool {
174        matches!(self, Self::Timeout)
175    }
176
177    /// Builds an HTTP error with canonical status text.
178    pub fn http(status: StatusCode, message: impl Into<String>, body: Option<Bytes>) -> Self {
179        Self::http_with_status_text(
180            status,
181            status.canonical_reason().unwrap_or("").to_string(),
182            message,
183            body,
184        )
185    }
186
187    /// Builds an HTTP error with explicit status text.
188    pub fn http_with_status_text(
189        status: StatusCode,
190        status_text: impl Into<String>,
191        message: impl Into<String>,
192        body: Option<Bytes>,
193    ) -> Self {
194        Self::Http {
195            status,
196            status_text: status_text.into(),
197            message: message.into(),
198            body,
199        }
200    }
201
202    /// Returns the HTTP status when this error is response-related.
203    pub fn status(&self) -> Option<StatusCode> {
204        match self {
205            Self::Http { status, .. } => Some(*status),
206            #[cfg(feature = "json")]
207            Self::Deserialize { status, .. } => Some(*status),
208            #[cfg(feature = "validate")]
209            Self::Validation { status, .. } => Some(*status),
210            _ => None,
211        }
212    }
213
214    /// Returns the canonical status text for [`Error::Http`].
215    pub fn status_text(&self) -> Option<&str> {
216        match self {
217            Self::Http { status_text, .. } => Some(status_text),
218            _ => None,
219        }
220    }
221
222    /// Returns the response body when present on HTTP, deserialize, or validation errors.
223    pub fn body(&self) -> Option<&Bytes> {
224        match self {
225            Self::Http { body, .. } => body.as_ref(),
226            #[cfg(feature = "json")]
227            Self::Deserialize { body, .. } => body.as_ref(),
228            #[cfg(feature = "validate")]
229            Self::Validation { body, .. } => body.as_ref(),
230            _ => None,
231        }
232    }
233
234    /// Returns `true` when transport retries were configured but all attempts failed.
235    pub fn is_retry_exhausted(&self) -> bool {
236        matches!(self, Self::RetryExhausted { .. })
237    }
238
239    /// Returns the last error from [`Error::RetryExhausted`] when present.
240    pub fn retry_exhausted_last(&self) -> Option<&Error> {
241        match self {
242            Self::RetryExhausted { last, .. } => last.as_deref(),
243            _ => None,
244        }
245    }
246
247    /// Returns `true` when the request was cancelled via [`CancellationToken`](crate::CancellationToken).
248    pub fn is_cancelled(&self) -> bool {
249        matches!(self, Self::Cancelled)
250    }
251
252    /// Returns `true` when the response body exceeded a configured size limit.
253    pub fn is_body_too_large(&self) -> bool {
254        matches!(self, Self::BodyTooLarge { .. })
255    }
256
257    /// Returns the configured byte limit when this error is [`Error::BodyTooLarge`].
258    pub fn body_too_large_limit(&self) -> Option<u64> {
259        match self {
260            Self::BodyTooLarge { limit } => Some(*limit),
261            _ => None,
262        }
263    }
264
265    /// Builds a hook failure for [`Hooks::on_request`](crate::hooks::Hooks::on_request) /
266    /// [`Hooks::on_response`](crate::hooks::Hooks::on_response).
267    pub fn hook(msg: impl Into<String>) -> Self {
268        Self::Hook(msg.into())
269    }
270
271    /// Returns `true` when the error is [`Error::Hook`](Self::Hook).
272    pub fn is_hook(&self) -> bool {
273        matches!(self, Self::Hook(_))
274    }
275
276    pub(crate) fn retry_exhausted(attempts: u32, last: Error) -> Self {
277        Self::RetryExhausted {
278            attempts,
279            last: Some(Box::new(last)),
280        }
281    }
282
283    /// Parses the error response body as JSON (for API error payloads).
284    ///
285    /// # Examples
286    ///
287    /// ```
288    /// use better_fetch::Error;
289    /// use http::StatusCode;
290    /// use serde::Deserialize;
291    ///
292    /// #[derive(Debug, Deserialize, PartialEq)]
293    /// struct ApiError {
294    ///     message: String,
295    /// }
296    ///
297    /// let err = Error::http_with_status_text(
298    ///     StatusCode::BAD_REQUEST,
299    ///     "Bad Request",
300    ///     "bad request",
301    ///     Some(bytes::Bytes::from_static(br#"{"message":"invalid"}"#)),
302    /// );
303    /// let api: ApiError = err.api_json().unwrap();
304    /// assert_eq!(api.message, "invalid");
305    /// ```
306    #[cfg(feature = "json")]
307    pub fn api_json<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
308        let body = self.body()?;
309        serde_json::from_slice(body).ok()
310    }
311
312    /// Parses and validates the error response body (feature `validate`).
313    #[cfg(feature = "validate")]
314    pub fn api_json_validated<T>(&self) -> Option<T>
315    where
316        T: serde::de::DeserializeOwned + garde::Validate,
317        T::Context: Default,
318    {
319        let body = self.body()?;
320        let value: T = serde_json::from_slice(body).ok()?;
321        value.validate().ok()?;
322        Some(value)
323    }
324}
325
326pub(crate) fn map_transport_error(err: reqwest::Error) -> Error {
327    if err.is_timeout() {
328        return Error::Timeout;
329    }
330
331    let kind = transport_kind_from_reqwest(&err);
332    Error::Transport {
333        kind,
334        message: err.to_string(),
335    }
336}
337
338fn transport_kind_from_reqwest(err: &reqwest::Error) -> TransportKind {
339    #[cfg(not(target_arch = "wasm32"))]
340    if err.is_connect() {
341        return TransportKind::Connect;
342    }
343
344    if err.is_body() {
345        TransportKind::Body
346    } else if err.is_decode() {
347        TransportKind::Decode
348    } else if err.is_redirect() {
349        TransportKind::Redirect
350    } else if err.is_request() {
351        TransportKind::Request
352    } else if err.is_builder() {
353        TransportKind::Builder
354    } else if err.is_upgrade() {
355        TransportKind::Upgrade
356    } else {
357        TransportKind::Other
358    }
359}
360
361#[cfg(all(test, feature = "json"))]
362mod tests {
363    use super::*;
364    use serde::Deserialize;
365
366    #[derive(Debug, Deserialize, PartialEq)]
367    struct ApiError {
368        message: String,
369    }
370
371    #[test]
372    fn api_json_parses_http_body() {
373        let err = Error::http_with_status_text(
374            StatusCode::BAD_REQUEST,
375            "Bad Request",
376            "bad request",
377            Some(bytes::Bytes::from_static(br#"{"message":"invalid"}"#)),
378        );
379        let api: ApiError = err.api_json().unwrap();
380        assert_eq!(api.message, "invalid");
381    }
382
383    #[test]
384    fn status_and_status_text_accessors() {
385        let err = Error::http(StatusCode::NOT_FOUND, "not found", None);
386        assert_eq!(err.status(), Some(StatusCode::NOT_FOUND));
387        assert_eq!(err.status_text(), Some("Not Found"));
388    }
389
390    #[test]
391    fn api_json_returns_none_without_body() {
392        let err = Error::http(StatusCode::INTERNAL_SERVER_ERROR, "err", None);
393        assert!(err.api_json::<ApiError>().is_none());
394    }
395
396    #[test]
397    fn hook_constructor_and_is_hook() {
398        let err = Error::hook("blocked");
399        assert!(err.is_hook());
400        assert!(matches!(err, Error::Hook(msg) if msg == "blocked"));
401    }
402
403    #[test]
404    fn retry_exhausted_helper_sets_flag() {
405        let err = Error::retry_exhausted(3, Error::Timeout);
406        assert!(err.is_retry_exhausted());
407        assert!(matches!(
408            err,
409            Error::RetryExhausted {
410                attempts: 3,
411                last: Some(_)
412            }
413        ));
414        assert!(matches!(err.retry_exhausted_last(), Some(Error::Timeout)));
415    }
416
417    #[test]
418    fn transport_helpers() {
419        let err = Error::transport(TransportKind::Connect, "connection refused");
420        assert!(err.is_transport());
421        assert_eq!(err.transport_kind(), Some(TransportKind::Connect));
422        assert_eq!(err.transport_detail(), Some("connection refused"));
423    }
424
425    #[test]
426    fn transport_message_defaults_to_other() {
427        let err = Error::transport_message("tower layer failed");
428        assert_eq!(err.transport_kind(), Some(TransportKind::Other));
429    }
430}