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