use http::Uri;
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("HTTP error: {0}")]
Http(#[from] http::Error),
#[error("hyper error: {0}")]
Hyper(#[from] hyper::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("TLS error: {0}")]
Tls(#[source] BoxError),
#[error("request timeout")]
Timeout,
#[error("connect timeout")]
ConnectTimeout,
#[error("read timeout")]
ReadTimeout,
#[error("invalid URL: {0}")]
InvalidUrl(String),
#[error("HTTP status error: {0}")]
Status(http::StatusCode),
#[error("redirect error: {0}")]
Redirect(String),
#[error("too many redirects (max {0})")]
TooManyRedirects(usize),
#[error("HTTPS required but URL scheme is {0}")]
HttpsOnly(String),
#[error("invalid header: {0}")]
InvalidHeader(String),
#[error("{0}")]
Other(#[source] BoxError),
}
#[derive(Debug)]
pub struct SendError {
error: Error,
url: Uri,
}
impl std::fmt::Display for SendError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(root) = self.error.hidden_root_cause() {
write!(
f,
"{}: {} for url ({})",
self.error,
root,
redact_url(&self.url)
)
} else {
write!(f, "{} for url ({})", self.error, redact_url(&self.url))
}
}
}
impl std::error::Error for SendError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.error)
}
}
impl SendError {
pub(crate) fn new(error: Error, url: Uri) -> Self {
Self { error, url }
}
pub fn url(&self) -> &Uri {
&self.url
}
pub fn error(&self) -> &Error {
&self.error
}
pub fn into_error(self) -> Error {
self.error
}
pub fn is_timeout(&self) -> bool {
self.error.is_timeout()
}
pub fn is_connect(&self) -> bool {
self.error.is_connect()
}
pub fn is_dns(&self) -> bool {
self.error.is_dns()
}
pub fn is_closed(&self) -> bool {
self.error.is_closed()
}
pub fn is_status(&self) -> bool {
self.error.is_status()
}
pub fn is_redirect(&self) -> bool {
self.error.is_redirect()
}
pub fn status(&self) -> Option<http::StatusCode> {
self.error.status()
}
pub fn root_cause(&self) -> &(dyn std::error::Error + 'static) {
self.error.root_cause()
}
}
impl From<SendError> for Error {
fn from(e: SendError) -> Self {
e.error
}
}
impl Error {
pub fn root_cause(&self) -> &(dyn std::error::Error + 'static) {
let mut source = self as &(dyn std::error::Error + 'static);
while let Some(next) = source.source() {
source = next;
}
source
}
pub fn is_connect(&self) -> bool {
match self {
Error::Io(_) | Error::Tls(_) | Error::ConnectTimeout => true,
Error::Hyper(e) => {
let mut source = std::error::Error::source(e);
while let Some(err) = source {
if let Some(io_err) = err.downcast_ref::<std::io::Error>() {
return matches!(
io_err.kind(),
std::io::ErrorKind::ConnectionRefused
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::AddrNotAvailable
| std::io::ErrorKind::AddrInUse
| std::io::ErrorKind::NotConnected
);
}
source = err.source();
}
false
}
_ => false,
}
}
pub fn is_timeout(&self) -> bool {
matches!(
self,
Error::Timeout | Error::ConnectTimeout | Error::ReadTimeout
)
}
pub fn is_status(&self) -> bool {
matches!(self, Error::Status(_))
}
pub fn status(&self) -> Option<http::StatusCode> {
match self {
Error::Status(code) => Some(*code),
_ => None,
}
}
pub fn is_redirect(&self) -> bool {
matches!(self, Error::Redirect(_) | Error::TooManyRedirects(_))
}
pub fn is_dns(&self) -> bool {
match self {
Error::Io(e) => {
let msg = e.to_string();
msg.contains("dns")
|| msg.contains("resolve")
|| msg.contains("Name or service not known")
|| msg.contains("nodename nor servname")
|| msg.contains("no DNS resolver")
}
Error::Hyper(e) => {
let mut source = std::error::Error::source(e);
while let Some(err) = source {
if let Some(io_err) = err.downcast_ref::<std::io::Error>() {
let msg = io_err.to_string();
return msg.contains("dns")
|| msg.contains("resolve")
|| msg.contains("Name or service not known")
|| msg.contains("nodename nor servname");
}
source = err.source();
}
false
}
Error::InvalidUrl(msg) => {
msg.contains("no DNS resolver") || msg.contains("cannot resolve")
}
_ => false,
}
}
pub fn is_closed(&self) -> bool {
use std::error::Error as _;
match self {
Error::Hyper(e) => {
if e.is_canceled() || e.is_closed() || e.is_incomplete_message() {
return true;
}
if let Some(io_err) = e.source().and_then(|s| s.downcast_ref::<std::io::Error>()) {
return matches!(
io_err.kind(),
std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::BrokenPipe
| std::io::ErrorKind::ConnectionAborted
);
}
false
}
Error::Io(e) => matches!(
e.kind(),
std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::BrokenPipe
| std::io::ErrorKind::ConnectionAborted
),
_ => false,
}
}
fn hidden_root_cause(&self) -> Option<&(dyn std::error::Error + 'static)> {
let mut source = std::error::Error::source(self)?;
let mut nested = false;
while let Some(next) = source.source() {
nested = true;
source = next;
}
if nested && !self.to_string().contains(&source.to_string()) {
Some(source)
} else {
None
}
}
}
fn redact_url(uri: &Uri) -> String {
if let Some(authority) = uri.authority() {
if authority.as_str().contains('@') {
let host_port = authority.host().to_owned()
+ &authority
.port()
.map(|p| format!(":{p}"))
.unwrap_or_default();
format!(
"{}://[redacted]@{}{}",
uri.scheme_str().unwrap_or("http"),
host_port,
uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/")
)
} else {
uri.to_string()
}
} else {
uri.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, thiserror::Error)]
#[error("outer layer")]
struct OuterLayer {
#[source]
source: InnerLayer,
}
#[derive(Debug, thiserror::Error)]
#[error("inner cause")]
struct InnerLayer;
#[test]
fn is_connect_for_io() {
let err = Error::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"refused",
));
assert!(err.is_connect());
assert!(!err.is_status());
assert!(!err.is_timeout());
assert!(!err.is_redirect());
}
#[test]
fn is_connect_for_tls() {
let err = Error::Tls("bad cert".into());
assert!(err.is_connect());
}
#[test]
fn is_connect_for_connect_timeout() {
let err = Error::ConnectTimeout;
assert!(err.is_connect());
assert!(err.is_timeout());
}
#[test]
fn read_timeout_not_connect() {
let err = Error::ReadTimeout;
assert!(!err.is_connect());
assert!(err.is_timeout());
}
#[test]
fn generic_timeout_not_connect() {
let err = Error::Timeout;
assert!(!err.is_connect());
assert!(err.is_timeout());
}
#[test]
fn is_status_and_status_accessor() {
let err = Error::Status(http::StatusCode::NOT_FOUND);
assert!(err.is_status());
assert_eq!(err.status(), Some(http::StatusCode::NOT_FOUND));
}
#[test]
fn status_returns_none_for_non_status() {
let err = Error::Timeout;
assert_eq!(err.status(), None);
}
#[test]
fn is_redirect_for_redirect() {
let err = Error::Redirect("missing Location".into());
assert!(err.is_redirect());
}
#[test]
fn is_redirect_for_too_many() {
let err = Error::TooManyRedirects(10);
assert!(err.is_redirect());
}
#[test]
fn non_connect_errors() {
assert!(!Error::Timeout.is_connect());
assert!(!Error::ReadTimeout.is_connect());
assert!(!Error::Status(http::StatusCode::OK).is_connect());
assert!(!Error::InvalidUrl("bad".into()).is_connect());
assert!(!Error::Redirect("nope".into()).is_connect());
assert!(!Error::TooManyRedirects(5).is_connect());
assert!(!Error::HttpsOnly("http".into()).is_connect());
assert!(!Error::InvalidHeader("bad".into()).is_connect());
assert!(!Error::Other("misc".into()).is_connect());
}
#[test]
fn display_formats() {
assert_eq!(Error::Timeout.to_string(), "request timeout");
assert!(Error::TooManyRedirects(10).to_string().contains("10"));
assert!(Error::HttpsOnly("http".into()).to_string().contains("http"));
}
#[test]
fn is_closed_for_io_connection_reset() {
let err = Error::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionReset,
"reset",
));
assert!(err.is_closed());
}
#[test]
fn is_closed_for_io_broken_pipe() {
let err = Error::Io(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"broken",
));
assert!(err.is_closed());
}
#[test]
fn is_closed_for_io_connection_aborted() {
let err = Error::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionAborted,
"aborted",
));
assert!(err.is_closed());
}
#[test]
fn is_closed_false_for_other_io() {
let err = Error::Io(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"timed out",
));
assert!(!err.is_closed());
}
#[test]
fn is_closed_false_for_non_io_errors() {
assert!(!Error::Timeout.is_closed());
assert!(!Error::ConnectTimeout.is_closed());
assert!(!Error::ReadTimeout.is_closed());
assert!(!Error::Status(http::StatusCode::OK).is_closed());
assert!(!Error::InvalidUrl("bad".into()).is_closed());
assert!(!Error::Redirect("nope".into()).is_closed());
assert!(!Error::TooManyRedirects(5).is_closed());
assert!(!Error::HttpsOnly("http".into()).is_closed());
assert!(!Error::InvalidHeader("bad".into()).is_closed());
assert!(!Error::Other("misc".into()).is_closed());
assert!(!Error::Tls("bad cert".into()).is_closed());
}
#[test]
fn send_error_accessors() {
let uri: Uri = "http://example.com/path".parse().unwrap();
let err = SendError::new(Error::Timeout, uri.clone());
assert_eq!(*err.url(), uri);
assert!(err.is_timeout());
assert!(!err.is_connect());
assert!(!err.is_status());
assert!(!err.is_redirect());
assert_eq!(err.status(), None);
}
#[test]
fn send_error_status_variant() {
let uri: Uri = "http://example.com/".parse().unwrap();
let err = SendError::new(Error::Status(http::StatusCode::NOT_FOUND), uri);
assert!(err.is_status());
assert_eq!(err.status(), Some(http::StatusCode::NOT_FOUND));
assert!(!err.is_timeout());
}
#[test]
fn send_error_connect_variant() {
let uri: Uri = "http://example.com/".parse().unwrap();
let err = SendError::new(Error::ConnectTimeout, uri);
assert!(err.is_connect());
assert!(err.is_timeout());
}
#[test]
fn send_error_redirect_variant() {
let uri: Uri = "http://example.com/".parse().unwrap();
let err = SendError::new(Error::Redirect("no location".into()), uri);
assert!(err.is_redirect());
}
#[test]
fn send_error_display() {
let uri: Uri = "http://example.com/path".parse().unwrap();
let err = SendError::new(Error::Timeout, uri);
let msg = err.to_string();
assert!(msg.contains("request timeout"));
assert!(msg.contains("example.com"));
}
#[test]
fn send_error_source() {
use std::error::Error as StdError;
let uri: Uri = "http://example.com/".parse().unwrap();
let err = SendError::new(Error::Timeout, uri);
assert!(err.source().is_some());
}
#[test]
fn send_error_error_ref() {
let uri: Uri = "http://example.com/".parse().unwrap();
let err = SendError::new(Error::Timeout, uri);
assert!(err.error().is_timeout());
}
#[test]
fn send_error_into_error() {
let uri: Uri = "http://example.com/".parse().unwrap();
let err = SendError::new(Error::Timeout, uri);
let inner = err.into_error();
assert!(inner.is_timeout());
}
#[test]
fn send_error_into_from() {
let uri: Uri = "http://example.com/".parse().unwrap();
let send_err = SendError::new(Error::ReadTimeout, uri);
let err: Error = send_err.into();
assert!(matches!(err, Error::ReadTimeout));
}
#[test]
fn boxed_tls_error_exposes_source_chain() {
use std::error::Error as StdError;
let err = Error::Tls(Box::new(OuterLayer { source: InnerLayer }));
let source = err.source().expect("TLS should expose boxed source");
assert_eq!(source.to_string(), "outer layer");
assert_eq!(err.root_cause().to_string(), "inner cause");
}
#[test]
fn boxed_other_error_exposes_source_chain() {
use std::error::Error as StdError;
let err = Error::Other(Box::new(OuterLayer { source: InnerLayer }));
let source = err.source().expect("Other should expose boxed source");
assert_eq!(source.to_string(), "outer layer");
assert_eq!(err.root_cause().to_string(), "inner cause");
}
#[test]
fn send_error_root_cause_forwards_to_underlying_error() {
let uri: Uri = "http://example.com/".parse().unwrap();
let err = SendError::new(
Error::Other(Box::new(OuterLayer { source: InnerLayer })),
uri,
);
assert_eq!(err.root_cause().to_string(), "inner cause");
}
#[test]
fn send_error_display_includes_hidden_root_cause_and_redacts_url() {
let uri: Uri = "http://user:password@example.com/path".parse().unwrap();
let err = SendError::new(Error::Tls(Box::new(OuterLayer { source: InnerLayer })), uri);
let display = err.to_string();
assert!(display.contains("TLS error: outer layer: inner cause"));
assert!(display.contains("http://[redacted]@example.com/path"));
assert!(!display.contains("user:password"));
}
#[test]
fn error_from_http_error() {
let err: Result<http::Request<()>, _> = http::Request::builder()
.method("GET")
.header("bad\nheader", "value")
.body(());
let http_err = err.unwrap_err();
let err: Error = Error::Http(http_err);
assert!(!err.is_closed());
}
#[test]
fn display_all_variants() {
assert!(
Error::ConnectTimeout
.to_string()
.contains("connect timeout")
);
assert!(Error::ReadTimeout.to_string().contains("read timeout"));
assert!(Error::InvalidUrl("bad".into()).to_string().contains("bad"));
assert!(
Error::InvalidHeader("hdr".into())
.to_string()
.contains("hdr")
);
assert!(Error::Tls("tls err".into()).to_string().contains("tls"));
assert!(Error::Other("other".into()).to_string().contains("other"));
let io_err = std::io::Error::other("io");
assert!(Error::Io(io_err).to_string().contains("io"));
}
#[test]
fn error_debug_format() {
let err = Error::Timeout;
let dbg = format!("{:?}", err);
assert!(dbg.contains("Timeout"));
}
#[cfg(feature = "tokio")]
#[tokio::test]
async fn is_closed_hyper_canceled() {
use crate::runtime::tokio_rt::TokioIo;
let (client_io, server_io) = tokio::io::duplex(1024);
let io = TokioIo::new(client_io);
let (mut sender, conn) = hyper::client::conn::http1::handshake(io)
.await
.expect("handshake");
tokio::spawn(async move {
let _ = conn.await;
});
drop(server_io);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let req = http::Request::builder()
.uri("http://example.com/")
.body(http_body_util::Empty::<bytes::Bytes>::new())
.unwrap();
let result = sender.send_request(req).await;
assert!(result.is_err(), "request should fail after server drops");
let hyper_err = result.unwrap_err();
assert!(
hyper_err.is_canceled() || hyper_err.is_closed() || hyper_err.is_incomplete_message(),
"expected canceled/closed/incomplete, got: {hyper_err:?}"
);
let err = Error::Hyper(hyper_err);
assert!(
err.is_closed(),
"Error::Hyper with canceled/closed should return true from is_closed()"
);
}
#[cfg(feature = "tokio")]
#[tokio::test]
async fn is_closed_hyper_non_canceled_returns_false() {
use crate::runtime::tokio_rt::TokioIo;
use tokio::io::AsyncWriteExt;
let (client_io, mut server_io) = tokio::io::duplex(1024);
let io = TokioIo::new(client_io);
let (mut sender, conn) = hyper::client::conn::http1::handshake(io)
.await
.expect("handshake");
tokio::spawn(async move {
let _ = conn.await;
});
let _ = server_io.write_all(b"NOT HTTP/1.1\r\n\r\n").await;
let req = http::Request::builder()
.uri("http://example.com/")
.body(http_body_util::Empty::<bytes::Bytes>::new())
.unwrap();
let result = sender.send_request(req).await;
if let Err(hyper_err) = result {
if !hyper_err.is_canceled()
&& !hyper_err.is_closed()
&& !hyper_err.is_incomplete_message()
{
let err = Error::Hyper(hyper_err);
assert!(
!err.is_closed(),
"parse error should not be considered closed"
);
}
}
}
#[test]
fn is_dns_false_for_addr_not_available() {
let err = Error::Io(std::io::Error::new(
std::io::ErrorKind::AddrNotAvailable,
"address not available",
));
assert!(!err.is_dns());
}
#[test]
fn is_dns_for_message_containing_dns() {
let err = Error::Io(std::io::Error::other("dns error"));
assert!(err.is_dns());
}
#[test]
fn is_dns_for_message_containing_resolve() {
let err = Error::Io(std::io::Error::other("failed to resolve host"));
assert!(err.is_dns());
}
#[test]
fn is_dns_for_no_dns_resolver() {
let err = Error::InvalidUrl("no DNS resolver configured".into());
assert!(err.is_dns());
}
#[test]
fn is_dns_false_for_non_io_errors() {
assert!(!Error::Timeout.is_dns());
assert!(!Error::ConnectTimeout.is_dns());
assert!(!Error::ReadTimeout.is_dns());
assert!(!Error::Status(http::StatusCode::OK).is_dns());
assert!(!Error::Tls("bad".into()).is_dns());
assert!(!Error::Redirect("nope".into()).is_dns());
assert!(!Error::TooManyRedirects(5).is_dns());
}
#[test]
fn send_error_is_dns_for_os_error() {
let uri: Uri = "http://example.com/".parse().unwrap();
let err = SendError::new(
Error::Io(std::io::Error::other(
"failed to lookup address information: Name or service not known",
)),
uri,
);
assert!(err.is_dns());
}
#[test]
fn send_error_is_dns_false() {
let uri: Uri = "http://example.com/".parse().unwrap();
let err = SendError::new(Error::Timeout, uri);
assert!(!err.is_dns());
}
#[test]
fn is_dns_false_for_connection_refused() {
let err = Error::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"connection refused",
));
assert!(!err.is_dns());
}
#[test]
fn is_closed_for_connection_refused() {
let err = Error::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"connection refused",
));
assert!(!err.is_closed());
}
#[cfg(feature = "tokio")]
#[tokio::test]
async fn is_connect_for_hyper_connection_error() {
use crate::runtime::tokio_rt::TokioIo;
use std::io;
use std::pin::Pin;
use std::task::{Context, Poll};
struct FailingIo;
impl tokio::io::AsyncRead for FailingIo {
fn poll_read(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
_buf: &mut tokio::io::ReadBuf<'_>,
) -> Poll<io::Result<()>> {
Poll::Ready(Err(io::Error::new(
io::ErrorKind::ConnectionRefused,
"connection refused",
)))
}
}
impl tokio::io::AsyncWrite for FailingIo {
fn poll_write(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
_buf: &[u8],
) -> Poll<io::Result<usize>> {
Poll::Ready(Err(io::Error::new(
io::ErrorKind::ConnectionRefused,
"connection refused",
)))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
}
let io = TokioIo::new(FailingIo);
let (mut sender, conn) =
hyper::client::conn::http1::handshake::<_, http_body_util::Empty<bytes::Bytes>>(io)
.await
.expect("handshake future should succeed (lazy)");
tokio::spawn(async move {
let _ = conn.await;
});
let req = http::Request::builder()
.uri("http://example.com/")
.body(http_body_util::Empty::<bytes::Bytes>::new())
.unwrap();
let result = sender.send_request(req).await;
match result {
Err(hyper_err) => {
let err = Error::Hyper(hyper_err);
assert!(
err.is_connect(),
"Error::Hyper wrapping a connection error should return true from is_connect()"
);
}
Ok(_) => panic!("expected send_request to fail on failing IO"),
}
}
#[test]
fn is_dns_for_invalid_url_cannot_resolve() {
let err = Error::InvalidUrl("cannot resolve host.invalid:80: dns error".into());
assert!(
err.is_dns(),
"Error::InvalidUrl with 'cannot resolve' should match is_dns()"
);
}
#[test]
fn is_dns_for_invalid_url_no_dns_resolver() {
let err = Error::InvalidUrl("no DNS resolver configured for host:80".into());
assert!(
err.is_dns(),
"Error::InvalidUrl with 'no DNS resolver' should match is_dns()"
);
}
#[test]
fn is_dns_for_invalid_url_unrelated() {
let err = Error::InvalidUrl("bad url format".into());
assert!(
!err.is_dns(),
"Error::InvalidUrl without DNS keywords should not match is_dns()"
);
}
}