Skip to main content

fast_down/reqwest/
error.rs

1//! Errors produced by the `reqwest` HTTP backend.
2//!
3//! Each type wraps the underlying `reqwest`/`http` value privately and forwards
4//! what a caller needs to render, classify, or inspect it, so code that does not
5//! opt in is not tied to the exact client version. [`StatusCodeError::into_response`]
6//! is the deliberate escape hatch for callers that do want the raw response.
7
8use reqwest::Response;
9use url::Url;
10
11/// Errors that can occur when getting a header value from a reqwest response.
12#[derive(thiserror::Error, Debug)]
13pub enum ReqwestGetHeaderError {
14    /// The requested header is not present.
15    #[error("Header not found")]
16    NotFound,
17}
18
19/// A failure while building the underlying HTTP client: an invalid proxy URL, a
20/// TLS backend that failed to initialize, and so on.
21///
22/// Transparent over the underlying client error: it renders and sources through
23/// to it, and derefs to it so its classification methods (for example
24/// `is_timeout`) are callable without naming the client crate.
25#[derive(Debug, thiserror::Error)]
26#[error(transparent)]
27pub struct BuildClientError(#[from] reqwest::Error);
28
29impl std::ops::Deref for BuildClientError {
30    type Target = reqwest::Error;
31    fn deref(&self) -> &Self::Target {
32        &self.0
33    }
34}
35
36/// A transport-level failure from the underlying HTTP client.
37///
38/// Transparent over the underlying client error: it renders and sources through
39/// to it, and derefs to it so its classification methods (for example
40/// `is_timeout`) are callable without naming the client crate.
41#[derive(Debug, thiserror::Error)]
42#[error(transparent)]
43pub struct RequestError(#[from] reqwest::Error);
44
45impl std::ops::Deref for RequestError {
46    type Target = reqwest::Error;
47    fn deref(&self) -> &Self::Target {
48        &self.0
49    }
50}
51
52/// A non-success HTTP response, captured without exposing the response type of
53/// the underlying HTTP client.
54///
55/// Status, URL, and headers are available synchronously. The body is **not**
56/// read up front: the failed response is kept alive and [`body`](Self::body)
57/// streams it on demand, so constructing the error is pure and does no I/O.
58/// Since the response is consumed to read the body, `body` takes `self`.
59///
60/// Reading stops at [`MAX_ERROR_BODY`] bytes: an error response belongs to the
61/// server, and a hostile or misconfigured one must not be able to make this
62/// allocate an unbounded buffer.
63#[derive(Debug, thiserror::Error)]
64#[error(
65    "Url: {}, Status Code: {}, Headers: {:?}",
66    .resp.url(),
67    .resp.status().as_u16(),
68    .resp.headers()
69)]
70pub struct StatusCodeError {
71    resp: Response,
72}
73
74/// Most bytes of an error body read by [`StatusCodeError::body`].
75pub const MAX_ERROR_BODY: usize = 64 * 1024;
76
77impl StatusCodeError {
78    /// Capture a failed response. No body is read here.
79    pub(crate) const fn from_response(resp: Response) -> Self {
80        Self { resp }
81    }
82
83    /// The HTTP status code of the failed response.
84    #[must_use]
85    pub fn status(&self) -> u16 {
86        self.resp.status().as_u16()
87    }
88
89    /// The URL the failed response came from.
90    #[must_use]
91    pub fn url(&self) -> &Url {
92        self.resp.url()
93    }
94
95    /// A header value from the failed response, if present and valid UTF-8.
96    #[must_use]
97    pub fn header(&self, name: &str) -> Option<&str> {
98        self.resp.headers().get(name).and_then(|v| v.to_str().ok())
99    }
100
101    /// Take back the raw failed response for full access to the underlying HTTP
102    /// client.
103    ///
104    /// This is an opt-in escape hatch: the signature names `reqwest::Response`,
105    /// but **only callers who use it** need to depend on `reqwest`. Code that
106    /// does not call it is unaffected and can keep using [`body`](Self::body) /
107    /// [`body_text`](Self::body_text) without naming the client crate.
108    ///
109    /// Consumes the error, since a response body can only be read once.
110    #[must_use]
111    pub fn into_response(self) -> Response {
112        self.resp
113    }
114
115    /// Read the body of the failed response, capped at [`MAX_ERROR_BODY`] bytes.
116    ///
117    /// Consumes the response; a body can only be read once. Stops once the cap
118    /// is reached instead of trusting a remote `Content-Length`, so a hostile
119    /// server cannot force an unbounded allocation.
120    pub async fn body(mut self) -> Vec<u8> {
121        let mut body = Vec::new();
122        while body.len() < MAX_ERROR_BODY {
123            match self.resp.chunk().await {
124                Ok(Some(chunk)) => {
125                    let remaining = MAX_ERROR_BODY - body.len();
126                    body.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
127                }
128                Ok(None) | Err(_) => break,
129            }
130        }
131        body
132    }
133
134    /// Read the body of the failed response and decode it as UTF-8, lossily.
135    pub async fn body_text(self) -> String {
136        String::from_utf8_lossy(&self.body().await).into_owned()
137    }
138}
139
140/// Errors that can occur when sending a reqwest request.
141#[derive(thiserror::Error, Debug)]
142#[non_exhaustive]
143pub enum ReqwestResponseError {
144    /// The request could not be completed (connection, TLS, timeout, …).
145    #[error(transparent)]
146    Request(RequestError),
147    /// The server answered with a non-success status code.
148    #[error(transparent)]
149    StatusCode(Box<StatusCodeError>),
150}
151
152impl ReqwestResponseError {
153    /// Capture a failed response as a status-code variant. No body is read.
154    pub(crate) fn from_response(resp: Response) -> Self {
155        Self::StatusCode(Box::new(StatusCodeError::from_response(resp)))
156    }
157
158    /// The status code of a non-success response, or `None` for a transport
159    /// failure.
160    #[must_use]
161    pub fn status_code(&self) -> Option<u16> {
162        match self {
163            Self::Request(_) => None,
164            Self::StatusCode(resp) => Some(resp.status()),
165        }
166    }
167
168    /// The URL of a non-success response, or `None` for a transport failure.
169    #[must_use]
170    pub fn url(&self) -> Option<&Url> {
171        match self {
172            Self::Request(_) => None,
173            Self::StatusCode(resp) => Some(resp.url()),
174        }
175    }
176
177    /// Whether the failure was a timeout (never true for a status code).
178    #[must_use]
179    pub fn is_timeout(&self) -> bool {
180        match self {
181            Self::Request(e) => e.is_timeout(),
182            Self::StatusCode(_) => false,
183        }
184    }
185
186    /// Whether the failure was a connection error (never true for a status
187    /// code).
188    #[must_use]
189    pub fn is_connect(&self) -> bool {
190        match self {
191            Self::Request(e) => e.is_connect(),
192            Self::StatusCode(_) => false,
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    #![allow(
200        clippy::unwrap_used,
201        clippy::expect_used,
202        clippy::panic,
203        clippy::significant_drop_tightening
204    )]
205    use super::*;
206    use crate::http::Prefetch;
207    use reqwest::{Client, StatusCode};
208
209    #[tokio::test]
210    async fn test_error_handling() {
211        let mut server = mockito::Server::new_async().await;
212        let client = Client::builder().no_proxy().build().unwrap();
213        let _mock1 = server
214            .mock("GET", "/404")
215            .with_status(404)
216            .create_async()
217            .await;
218
219        let url = Url::parse(&format!("{}/404", server.url())).unwrap();
220        match client.prefetch(url).await {
221            Ok(info) => unreachable!("404 status code should not success: {info:?}"),
222            Err((err, _)) => match err {
223                ReqwestResponseError::Request(error) => unreachable!("{error:?}"),
224                ReqwestResponseError::StatusCode(resp) => {
225                    assert_eq!(resp.status(), StatusCode::NOT_FOUND.as_u16());
226                }
227            },
228        }
229    }
230
231    #[tokio::test]
232    async fn status_code_error_exposes_headers_and_body() {
233        // A non-success response must be captured losslessly: the caller can
234        // read the status, a response header, and the raw body without naming
235        // reqwest.
236        let mut server = mockito::Server::new_async().await;
237        let client = Client::builder().no_proxy().build().unwrap();
238        let _mock = server
239            .mock("GET", "/boom")
240            .with_status(502)
241            .with_header("x-reason", "upstream down")
242            .with_body("bad gateway")
243            .create_async()
244            .await;
245
246        let url = Url::parse(&format!("{}/boom", server.url())).unwrap();
247        let (err, _) = client.prefetch(url).await.expect_err("502 must fail");
248        assert_eq!(err.status_code(), Some(502));
249        assert_eq!(err.url().unwrap().path(), "/boom");
250        match err {
251            ReqwestResponseError::StatusCode(resp) => {
252                assert_eq!(resp.status(), 502);
253                assert_eq!(resp.header("x-reason"), Some("upstream down"));
254                assert_eq!(resp.body_text().await, "bad gateway");
255            }
256            ReqwestResponseError::Request(e) => unreachable!("{e:?}"),
257        }
258    }
259
260    #[tokio::test]
261    async fn status_code_error_into_response_escape_hatch() {
262        // The raw `reqwest::Response` is retrievable for callers that opt in,
263        // with full access to the underlying client's API.
264        let mut server = mockito::Server::new_async().await;
265        let client = Client::builder().no_proxy().build().unwrap();
266        let _mock = server
267            .mock("GET", "/raw")
268            .with_status(503)
269            .with_body("unavailable")
270            .create_async()
271            .await;
272
273        let url = Url::parse(&format!("{}/raw", server.url())).unwrap();
274        let (err, _) = client.prefetch(url).await.expect_err("503 must fail");
275        let ReqwestResponseError::StatusCode(resp) = err else {
276            unreachable!("expected a status code error")
277        };
278        let raw = resp.into_response();
279        assert_eq!(raw.status(), StatusCode::SERVICE_UNAVAILABLE);
280        assert_eq!(raw.text().await.unwrap(), "unavailable");
281    }
282
283    #[tokio::test]
284    async fn status_code_error_caps_body() {
285        // The body read is bounded even when the server sends more than
286        // `MAX_ERROR_BODY` bytes.
287        let mut server = mockito::Server::new_async().await;
288        let client = Client::builder().no_proxy().build().unwrap();
289        let payload = vec![b'x'; MAX_ERROR_BODY + 4096];
290        let _mock = server
291            .mock("GET", "/big")
292            .with_status(500)
293            .with_body(payload)
294            .create_async()
295            .await;
296
297        let url = Url::parse(&format!("{}/big", server.url())).unwrap();
298        let (err, _) = client.prefetch(url).await.expect_err("500 must fail");
299        match err {
300            ReqwestResponseError::StatusCode(resp) => {
301                assert_eq!(resp.body().await.len(), MAX_ERROR_BODY);
302            }
303            ReqwestResponseError::Request(e) => unreachable!("{e:?}"),
304        }
305    }
306
307    #[tokio::test]
308    async fn request_error_classifies_transport_failure() {
309        // A dropped connection surfaces as `Request`, and the classification
310        // helpers must report it without the caller naming reqwest.
311        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
312        let addr = listener.local_addr().unwrap();
313        tokio::spawn(async move {
314            loop {
315                let (stream, _) = listener.accept().await.unwrap();
316                drop(stream);
317            }
318        });
319        let client = Client::builder().no_proxy().build().unwrap();
320        let url = Url::parse(&format!("http://{addr}/x")).unwrap();
321        let (err, _) = client
322            .prefetch(url)
323            .await
324            .expect_err("dropped conn must fail");
325        match err {
326            ReqwestResponseError::Request(e) => {
327                assert!(!e.is_timeout());
328                assert!(e.is_connect() || e.is_request());
329                assert_eq!(e.status(), None);
330            }
331            ReqwestResponseError::StatusCode(resp) => unreachable!("{}", resp.status()),
332        }
333    }
334}