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;
8use std::sync::Arc;
9
10use bytes::Bytes;
11use http::StatusCode;
12use thiserror::Error;
13
14/// Classification of underlying transport failures (connection, body, decode, etc.).
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum TransportKind {
17    /// Connection failed (TCP/TLS/DNS and similar).
18    Connect,
19    /// Request or response body error.
20    Body,
21    /// Response body decoding error (e.g. decompression).
22    Decode,
23    /// Redirect policy violation.
24    Redirect,
25    /// Error building or sending the request.
26    Request,
27    /// Invalid request configuration.
28    Builder,
29    /// Protocol upgrade failure.
30    Upgrade,
31    /// Unclassified transport failure.
32    Other,
33}
34
35impl fmt::Display for TransportKind {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            Self::Connect => write!(f, "connect"),
39            Self::Body => write!(f, "body"),
40            Self::Decode => write!(f, "decode"),
41            Self::Redirect => write!(f, "redirect"),
42            Self::Request => write!(f, "request"),
43            Self::Builder => write!(f, "builder"),
44            Self::Upgrade => write!(f, "upgrade"),
45            Self::Other => write!(f, "other"),
46        }
47    }
48}
49
50/// Error type for better-fetch operations.
51#[derive(Debug, Error, Clone)]
52#[must_use = "errors must be handled or propagated with `?`"]
53pub enum Error {
54    /// Base URL parsing failed ([`ClientBuilder::base_url`](crate::ClientBuilder::base_url)).
55    #[error("invalid base URL: {0}")]
56    InvalidBaseUrl(#[from] url::ParseError),
57
58    /// Underlying transport failure (connection, DNS, body read, etc.).
59    #[error("transport error ({kind}): {message}")]
60    Transport {
61        /// Coarse category aligned with reqwest's `is_*` helpers.
62        kind: TransportKind,
63        /// Human-readable detail (typically from the underlying error's `Display`).
64        message: String,
65        /// Underlying error when available (e.g. reqwest).
66        #[source]
67        source: Option<Arc<dyn std::error::Error + Send + Sync>>,
68    },
69
70    /// Non-success HTTP response (when using throw mode or `send_json`).
71    #[error("HTTP {status} {status_text}: {message}")]
72    Http {
73        /// HTTP status code.
74        status: StatusCode,
75        /// Canonical reason phrase.
76        status_text: String,
77        /// Human-readable message.
78        message: String,
79        /// Response body when buffered.
80        body: Option<Bytes>,
81    },
82
83    /// JSON response could not be deserialized (feature `json`).
84    #[cfg(feature = "json")]
85    #[error("failed to deserialize response body: {message}")]
86    Deserialize {
87        status: StatusCode,
88        message: String,
89        body: Option<Bytes>,
90    },
91
92    /// Response failed garde validation (feature `validate`).
93    #[cfg(feature = "validate")]
94    #[error("response validation failed: {message}")]
95    Validation {
96        status: StatusCode,
97        message: String,
98        body: Option<Bytes>,
99    },
100
101    /// Request exceeded the configured timeout.
102    #[error("request timed out")]
103    Timeout,
104
105    /// Request was cancelled via [`CancellationToken`](crate::CancellationToken).
106    #[error("request was cancelled")]
107    Cancelled,
108
109    /// Response body exceeded [`ClientBuilder::max_response_bytes`](crate::ClientBuilder::max_response_bytes)
110    /// or a per-request [`RequestBuilder::max_response_bytes`](crate::RequestBuilder::max_response_bytes) limit
111    /// on buffered (`send` / `send_json`) or streaming responses.
112    #[error("response body exceeded limit of {limit} bytes")]
113    BodyTooLarge {
114        /// Configured maximum response size in bytes.
115        limit: u64,
116    },
117
118    /// [`ClientBuilder::build`](crate::ClientBuilder::build) without [`ClientBuilder::base_url`](crate::ClientBuilder::base_url).
119    #[error("client base URL is required; call ClientBuilder::base_url")]
120    MissingBaseUrl,
121
122    /// Transport retries were exhausted.
123    #[error("retries exhausted after {attempts} attempts")]
124    RetryExhausted {
125        /// Total attempts made (initial + retries).
126        attempts: u32,
127        /// Last error before retries were exhausted, when available.
128        last: Option<Box<Error>>,
129    },
130
131    /// Returned from [`on_request`](crate::hooks::Hooks::on_request) or
132    /// [`on_response`](crate::hooks::Hooks::on_response) to abort the pipeline.
133    /// Prefer constructing this with [`Error::hook`](Self::hook) rather than [`Error::Other`](Self::Other).
134    #[error("hook error: {0}")]
135    Hook(String),
136
137    /// Query parameter serialization failed (typed endpoint query).
138    ///
139    /// Returned from [`EndpointRequestBuilder::query`](crate::EndpointRequestBuilder::query) and
140    /// [`EndpointQuery::apply_query`](crate::EndpointQuery::apply_query) when serde serialization fails (since 0.4.0).
141    #[error("failed to serialize query: {0}")]
142    QuerySerialize(String),
143
144    /// Invalid HTTP header name ([`RequestBuilder::header`](crate::RequestBuilder::header)).
145    #[error("invalid header name: {0}")]
146    InvalidHeaderName(String),
147
148    /// Invalid HTTP header value ([`RequestBuilder::header`](crate::RequestBuilder::header)).
149    #[error("invalid header value: {0}")]
150    InvalidHeaderValue(String),
151
152    /// A `:param` segment in the path template was not supplied.
153    #[error("missing path parameter for `{0}`")]
154    MissingPathParam(String),
155
156    /// Route not registered in a strict [`SchemaRegistry`](crate::SchemaRegistry) (feature `schema`).
157    #[error("route not in schema registry: {method} {path}")]
158    SchemaRoute {
159        /// HTTP method.
160        method: String,
161        /// Path template.
162        path: String,
163    },
164
165    /// JSON Schema validation failed (feature `schema-validate`).
166    #[cfg(feature = "schema-validate")]
167    #[error("JSON schema validation failed ({phase}): {message}")]
168    SchemaValidation {
169        /// `"request"` or `"response"`.
170        phase: &'static str,
171        /// Validator detail.
172        message: String,
173    },
174
175    /// Invalid `Authorization` header value.
176    #[error("invalid authorization header: {0}")]
177    InvalidAuthHeader(String),
178
179    /// Request body cannot be replayed for automatic retry (stream or multipart).
180    #[error("automatic retry is not supported with non-replayable request bodies")]
181    NonReplayableBody,
182
183    /// Request body failed validation before send (feature `validate`).
184    #[cfg(feature = "validate")]
185    #[error("request validation failed: {message}")]
186    RequestValidation {
187        /// Validation error detail.
188        message: String,
189    },
190
191    /// I/O error (file writes, etc.).
192    #[error("I/O error: {0}")]
193    Io(String),
194
195    /// Internal client configuration error.
196    #[error("configuration error: {0}")]
197    Config(String),
198
199    /// Catch-all for rare plugin errors.
200    #[error("{0}")]
201    Other(String),
202}
203
204impl Error {
205    /// Builds a transport error with an explicit [`TransportKind`].
206    pub fn transport(kind: TransportKind, message: impl Into<String>) -> Self {
207        Self::Transport {
208            kind,
209            message: message.into(),
210            source: None,
211        }
212    }
213
214    /// Builds a transport error with an underlying source error.
215    pub fn transport_with_source(
216        kind: TransportKind,
217        message: impl Into<String>,
218        source: impl std::error::Error + Send + Sync + 'static,
219    ) -> Self {
220        Self::Transport {
221            kind,
222            message: message.into(),
223            source: Some(Arc::new(source)),
224        }
225    }
226
227    /// Builds a transport error with [`TransportKind::Other`].
228    pub fn transport_message(message: impl Into<String>) -> Self {
229        Self::transport(TransportKind::Other, message)
230    }
231
232    /// Returns the transport error source when present.
233    pub fn transport_source(&self) -> Option<&(dyn std::error::Error + Send + Sync)> {
234        match self {
235            Self::Transport {
236                source: Some(s), ..
237            } => Some(s.as_ref()),
238            _ => None,
239        }
240    }
241
242    /// Returns the transport failure category when this error is [`Error::Transport`].
243    pub fn transport_kind(&self) -> Option<TransportKind> {
244        match self {
245            Self::Transport { kind, .. } => Some(*kind),
246            _ => None,
247        }
248    }
249
250    /// Returns the transport error detail string when this error is [`Error::Transport`].
251    pub fn transport_detail(&self) -> Option<&str> {
252        match self {
253            Self::Transport { message, .. } => Some(message),
254            _ => None,
255        }
256    }
257
258    /// Returns `true` when this error is [`Error::Transport`].
259    pub fn is_transport(&self) -> bool {
260        matches!(self, Self::Transport { .. })
261    }
262
263    /// Returns `true` when this error is [`Error::Timeout`].
264    pub fn is_timeout(&self) -> bool {
265        matches!(self, Self::Timeout)
266    }
267
268    /// Builds an HTTP error with canonical status text.
269    pub fn http(status: StatusCode, message: impl Into<String>, body: Option<Bytes>) -> Self {
270        Self::http_with_status_text(
271            status,
272            status.canonical_reason().unwrap_or("").to_string(),
273            message,
274            body,
275        )
276    }
277
278    /// Builds an HTTP error with explicit status text.
279    pub fn http_with_status_text(
280        status: StatusCode,
281        status_text: impl Into<String>,
282        message: impl Into<String>,
283        body: Option<Bytes>,
284    ) -> Self {
285        Self::Http {
286            status,
287            status_text: status_text.into(),
288            message: message.into(),
289            body,
290        }
291    }
292
293    /// Builds an HTTP error using canonical status text and a message derived from `body` when present.
294    pub(crate) fn http_error_for_status(status: StatusCode, body: Option<Bytes>) -> Self {
295        let status_text = status
296            .canonical_reason()
297            .unwrap_or("request failed")
298            .to_string();
299        let message = body
300            .as_ref()
301            .and_then(|b| std::str::from_utf8(b).ok())
302            .map(|s| s.chars().take(512).collect::<String>())
303            .filter(|s| !s.is_empty())
304            .unwrap_or_else(|| status_text.clone());
305        Self::http_with_status_text(status, status_text, message, body)
306    }
307
308    /// Builds a query serialization error.
309    pub fn query_serialize(message: impl Into<String>) -> Self {
310        Self::QuerySerialize(message.into())
311    }
312
313    /// Returns the HTTP status when this error is response-related.
314    pub fn status(&self) -> Option<StatusCode> {
315        match self {
316            Self::Http { status, .. } => Some(*status),
317            #[cfg(feature = "json")]
318            Self::Deserialize { status, .. } => Some(*status),
319            #[cfg(feature = "validate")]
320            Self::Validation { status, .. } => Some(*status),
321            _ => None,
322        }
323    }
324
325    /// Returns the canonical status text for [`Error::Http`].
326    pub fn status_text(&self) -> Option<&str> {
327        match self {
328            Self::Http { status_text, .. } => Some(status_text),
329            _ => None,
330        }
331    }
332
333    /// Returns the response body when present on HTTP, deserialize, or validation errors.
334    pub fn body(&self) -> Option<&Bytes> {
335        match self {
336            Self::Http { body, .. } => body.as_ref(),
337            #[cfg(feature = "json")]
338            Self::Deserialize { body, .. } => body.as_ref(),
339            #[cfg(feature = "validate")]
340            Self::Validation { body, .. } => body.as_ref(),
341            _ => None,
342        }
343    }
344
345    /// Returns `true` when transport retries were configured but all attempts failed.
346    pub fn is_retry_exhausted(&self) -> bool {
347        matches!(self, Self::RetryExhausted { .. })
348    }
349
350    /// Returns the last error from [`Error::RetryExhausted`] when present.
351    pub fn retry_exhausted_last(&self) -> Option<&Error> {
352        match self {
353            Self::RetryExhausted { last, .. } => last.as_deref(),
354            _ => None,
355        }
356    }
357
358    /// Returns `true` when the request was cancelled via [`CancellationToken`](crate::CancellationToken).
359    pub fn is_cancelled(&self) -> bool {
360        matches!(self, Self::Cancelled)
361    }
362
363    /// Returns `true` when the response body exceeded a configured size limit.
364    pub fn is_body_too_large(&self) -> bool {
365        matches!(self, Self::BodyTooLarge { .. })
366    }
367
368    /// Returns the configured byte limit when this error is [`Error::BodyTooLarge`].
369    pub fn body_too_large_limit(&self) -> Option<u64> {
370        match self {
371            Self::BodyTooLarge { limit } => Some(*limit),
372            _ => None,
373        }
374    }
375
376    /// Builds a hook failure for [`Hooks::on_request`](crate::hooks::Hooks::on_request) /
377    /// [`Hooks::on_response`](crate::hooks::Hooks::on_response).
378    pub fn hook(msg: impl Into<String>) -> Self {
379        Self::Hook(msg.into())
380    }
381
382    /// Returns `true` when the error is [`Error::Hook`](Self::Hook).
383    pub fn is_hook(&self) -> bool {
384        matches!(self, Self::Hook(_))
385    }
386
387    pub(crate) fn retry_exhausted(attempts: u32, last: Error) -> Self {
388        Self::RetryExhausted {
389            attempts,
390            last: Some(Box::new(last)),
391        }
392    }
393
394    /// Parses the error response body as JSON (for API error payloads).
395    ///
396    /// # Examples
397    ///
398    /// ```
399    /// use better_fetch::Error;
400    /// use http::StatusCode;
401    /// use serde::Deserialize;
402    ///
403    /// #[derive(Debug, Deserialize, PartialEq)]
404    /// struct ApiError {
405    ///     message: String,
406    /// }
407    ///
408    /// let err = Error::http_with_status_text(
409    ///     StatusCode::BAD_REQUEST,
410    ///     "Bad Request",
411    ///     "bad request",
412    ///     Some(bytes::Bytes::from_static(br#"{"message":"invalid"}"#)),
413    /// );
414    /// let api: ApiError = err.api_json().unwrap();
415    /// assert_eq!(api.message, "invalid");
416    /// ```
417    #[cfg(feature = "json")]
418    pub fn api_json<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
419        let body = self.body()?;
420        serde_json::from_slice(body).ok()
421    }
422
423    /// Parses and validates the error response body (feature `validate`).
424    #[cfg(feature = "validate")]
425    pub fn api_json_validated<T>(&self) -> Option<T>
426    where
427        T: serde::de::DeserializeOwned + garde::Validate,
428        T::Context: Default,
429    {
430        let body = self.body()?;
431        let value: T = serde_json::from_slice(body).ok()?;
432        value.validate().ok()?;
433        Some(value)
434    }
435}
436
437pub(crate) fn map_transport_error(err: reqwest::Error) -> Error {
438    if err.is_timeout() {
439        return Error::Timeout;
440    }
441
442    let kind = transport_kind_from_reqwest(&err);
443    let message = err.to_string();
444    Error::Transport {
445        kind,
446        message,
447        source: Some(Arc::new(err)),
448    }
449}
450
451fn transport_kind_from_reqwest(err: &reqwest::Error) -> TransportKind {
452    #[cfg(not(target_arch = "wasm32"))]
453    if err.is_connect() {
454        return TransportKind::Connect;
455    }
456
457    if err.is_body() {
458        TransportKind::Body
459    } else if err.is_decode() {
460        TransportKind::Decode
461    } else if err.is_redirect() {
462        TransportKind::Redirect
463    } else if err.is_request() {
464        TransportKind::Request
465    } else if err.is_builder() {
466        TransportKind::Builder
467    } else if err.is_upgrade() {
468        TransportKind::Upgrade
469    } else {
470        TransportKind::Other
471    }
472}
473
474#[cfg(all(test, feature = "json"))]
475mod tests {
476    use super::*;
477    use serde::Deserialize;
478
479    #[derive(Debug, Deserialize, PartialEq)]
480    struct ApiError {
481        message: String,
482    }
483
484    #[test]
485    fn api_json_parses_http_body() {
486        let err = Error::http_with_status_text(
487            StatusCode::BAD_REQUEST,
488            "Bad Request",
489            "bad request",
490            Some(bytes::Bytes::from_static(br#"{"message":"invalid"}"#)),
491        );
492        let api: ApiError = err.api_json().unwrap();
493        assert_eq!(api.message, "invalid");
494    }
495
496    #[test]
497    fn status_and_status_text_accessors() {
498        let err = Error::http(StatusCode::NOT_FOUND, "not found", None);
499        assert_eq!(err.status(), Some(StatusCode::NOT_FOUND));
500        assert_eq!(err.status_text(), Some("Not Found"));
501    }
502
503    #[test]
504    fn api_json_returns_none_without_body() {
505        let err = Error::http(StatusCode::INTERNAL_SERVER_ERROR, "err", None);
506        assert!(err.api_json::<ApiError>().is_none());
507    }
508
509    #[test]
510    fn hook_constructor_and_is_hook() {
511        let err = Error::hook("blocked");
512        assert!(err.is_hook());
513        assert!(matches!(err, Error::Hook(msg) if msg == "blocked"));
514    }
515
516    #[test]
517    fn retry_exhausted_helper_sets_flag() {
518        let err = Error::retry_exhausted(3, Error::Timeout);
519        assert!(err.is_retry_exhausted());
520        assert!(matches!(
521            err,
522            Error::RetryExhausted {
523                attempts: 3,
524                last: Some(_)
525            }
526        ));
527        assert!(matches!(err.retry_exhausted_last(), Some(Error::Timeout)));
528    }
529
530    #[test]
531    fn transport_helpers() {
532        let err = Error::transport(TransportKind::Connect, "connection refused");
533        assert!(err.is_transport());
534        assert_eq!(err.transport_kind(), Some(TransportKind::Connect));
535        assert_eq!(err.transport_detail(), Some("connection refused"));
536    }
537
538    #[test]
539    fn transport_message_defaults_to_other() {
540        let err = Error::transport_message("tower layer failed");
541        assert_eq!(err.transport_kind(), Some(TransportKind::Other));
542    }
543}