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