use reqwest::Response;
use url::Url;
#[derive(thiserror::Error, Debug)]
pub enum ReqwestGetHeaderError {
#[error("Header not found")]
NotFound,
}
#[derive(Debug, thiserror::Error)]
#[error(transparent)]
pub struct BuildClientError(#[from] reqwest::Error);
impl std::ops::Deref for BuildClientError {
type Target = reqwest::Error;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, thiserror::Error)]
#[error(transparent)]
pub struct RequestError(#[from] reqwest::Error);
impl std::ops::Deref for RequestError {
type Target = reqwest::Error;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, thiserror::Error)]
#[error(
"Url: {}, Status Code: {}, Headers: {:?}",
.resp.url(),
.resp.status().as_u16(),
.resp.headers()
)]
pub struct StatusCodeError {
resp: Response,
}
pub const MAX_ERROR_BODY: usize = 64 * 1024;
impl StatusCodeError {
pub(crate) const fn from_response(resp: Response) -> Self {
Self { resp }
}
#[must_use]
pub fn status(&self) -> u16 {
self.resp.status().as_u16()
}
#[must_use]
pub fn url(&self) -> &Url {
self.resp.url()
}
#[must_use]
pub fn header(&self, name: &str) -> Option<&str> {
self.resp.headers().get(name).and_then(|v| v.to_str().ok())
}
#[must_use]
pub fn into_response(self) -> Response {
self.resp
}
pub async fn body(mut self) -> Vec<u8> {
let mut body = Vec::new();
while body.len() < MAX_ERROR_BODY {
match self.resp.chunk().await {
Ok(Some(chunk)) => {
let remaining = MAX_ERROR_BODY - body.len();
body.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
}
Ok(None) | Err(_) => break,
}
}
body
}
pub async fn body_text(self) -> String {
String::from_utf8_lossy(&self.body().await).into_owned()
}
}
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum ReqwestResponseError {
#[error(transparent)]
Request(RequestError),
#[error(transparent)]
StatusCode(Box<StatusCodeError>),
}
impl ReqwestResponseError {
pub(crate) fn from_response(resp: Response) -> Self {
Self::StatusCode(Box::new(StatusCodeError::from_response(resp)))
}
#[must_use]
pub fn status_code(&self) -> Option<u16> {
match self {
Self::Request(_) => None,
Self::StatusCode(resp) => Some(resp.status()),
}
}
#[must_use]
pub fn url(&self) -> Option<&Url> {
match self {
Self::Request(_) => None,
Self::StatusCode(resp) => Some(resp.url()),
}
}
#[must_use]
pub fn is_timeout(&self) -> bool {
match self {
Self::Request(e) => e.is_timeout(),
Self::StatusCode(_) => false,
}
}
#[must_use]
pub fn is_connect(&self) -> bool {
match self {
Self::Request(e) => e.is_connect(),
Self::StatusCode(_) => false,
}
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::significant_drop_tightening
)]
use super::*;
use crate::http::Prefetch;
use reqwest::{Client, StatusCode};
#[tokio::test]
async fn test_error_handling() {
let mut server = mockito::Server::new_async().await;
let client = Client::builder().no_proxy().build().unwrap();
let _mock1 = server
.mock("GET", "/404")
.with_status(404)
.create_async()
.await;
let url = Url::parse(&format!("{}/404", server.url())).unwrap();
match client.prefetch(url).await {
Ok(info) => unreachable!("404 status code should not success: {info:?}"),
Err((err, _)) => match err {
ReqwestResponseError::Request(error) => unreachable!("{error:?}"),
ReqwestResponseError::StatusCode(resp) => {
assert_eq!(resp.status(), StatusCode::NOT_FOUND.as_u16());
}
},
}
}
#[tokio::test]
async fn status_code_error_exposes_headers_and_body() {
let mut server = mockito::Server::new_async().await;
let client = Client::builder().no_proxy().build().unwrap();
let _mock = server
.mock("GET", "/boom")
.with_status(502)
.with_header("x-reason", "upstream down")
.with_body("bad gateway")
.create_async()
.await;
let url = Url::parse(&format!("{}/boom", server.url())).unwrap();
let (err, _) = client.prefetch(url).await.expect_err("502 must fail");
assert_eq!(err.status_code(), Some(502));
assert_eq!(err.url().unwrap().path(), "/boom");
match err {
ReqwestResponseError::StatusCode(resp) => {
assert_eq!(resp.status(), 502);
assert_eq!(resp.header("x-reason"), Some("upstream down"));
assert_eq!(resp.body_text().await, "bad gateway");
}
ReqwestResponseError::Request(e) => unreachable!("{e:?}"),
}
}
#[tokio::test]
async fn status_code_error_into_response_escape_hatch() {
let mut server = mockito::Server::new_async().await;
let client = Client::builder().no_proxy().build().unwrap();
let _mock = server
.mock("GET", "/raw")
.with_status(503)
.with_body("unavailable")
.create_async()
.await;
let url = Url::parse(&format!("{}/raw", server.url())).unwrap();
let (err, _) = client.prefetch(url).await.expect_err("503 must fail");
let ReqwestResponseError::StatusCode(resp) = err else {
unreachable!("expected a status code error")
};
let raw = resp.into_response();
assert_eq!(raw.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(raw.text().await.unwrap(), "unavailable");
}
#[tokio::test]
async fn status_code_error_caps_body() {
let mut server = mockito::Server::new_async().await;
let client = Client::builder().no_proxy().build().unwrap();
let payload = vec![b'x'; MAX_ERROR_BODY + 4096];
let _mock = server
.mock("GET", "/big")
.with_status(500)
.with_body(payload)
.create_async()
.await;
let url = Url::parse(&format!("{}/big", server.url())).unwrap();
let (err, _) = client.prefetch(url).await.expect_err("500 must fail");
match err {
ReqwestResponseError::StatusCode(resp) => {
assert_eq!(resp.body().await.len(), MAX_ERROR_BODY);
}
ReqwestResponseError::Request(e) => unreachable!("{e:?}"),
}
}
#[tokio::test]
async fn request_error_classifies_transport_failure() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
drop(stream);
}
});
let client = Client::builder().no_proxy().build().unwrap();
let url = Url::parse(&format!("http://{addr}/x")).unwrap();
let (err, _) = client
.prefetch(url)
.await
.expect_err("dropped conn must fail");
match err {
ReqwestResponseError::Request(e) => {
assert!(!e.is_timeout());
assert!(e.is_connect() || e.is_request());
assert_eq!(e.status(), None);
}
ReqwestResponseError::StatusCode(resp) => unreachable!("{}", resp.status()),
}
}
}