use std::error::Error;
use std::time::{Duration, SystemTime, SystemTimeError};
use std::{io, iter};
use http::status::StatusCode;
use http::{Extensions, Method};
use itertools::Itertools;
use reqwest::{Request, Response};
use reqwest_middleware::{Middleware, Next};
use reqwest_retry::policies::ExponentialBackoff;
use reqwest_retry::{
RetryPolicy, RetryTransientMiddleware, Retryable, RetryableStrategy, default_on_request_error,
default_on_request_success,
};
use rustls::{AlertDescription, Error as RustlsError};
use tracing::{debug, trace};
use uv_redacted::SafeUrl;
use crate::{RequestBuilder, WrappedReqwestError};
pub(crate) struct UvRetryMiddleware(pub(crate) ExponentialBackoff);
#[async_trait::async_trait]
impl Middleware for UvRetryMiddleware {
async fn handle(
&self,
request: Request,
extensions: &mut Extensions,
next: Next<'_>,
) -> reqwest_middleware::Result<Response> {
let strategy = UvRetryableStrategy(request.method().clone());
RetryTransientMiddleware::new_with_policy_and_strategy(self.0, strategy)
.handle(request, extensions, next)
.await
}
}
struct UvRetryableStrategy(Method);
impl RetryableStrategy for UvRetryableStrategy {
fn handle(&self, res: &Result<Response, reqwest_middleware::Error>) -> Option<Retryable> {
let retryable = match res {
Ok(success) => default_on_request_success(success),
Err(err) => retryable_on_request_failure_inner(err, Some(&self.0)),
};
if retryable == Some(Retryable::Transient) {
match res {
Ok(response) => {
debug!(
"Transient request failure for: {}",
SafeUrl::ref_cast(response.url())
);
}
Err(err) => {
let url = request_error_url(err);
let redact = |message: &str| {
url.map_or_else(|| message.to_owned(), |url| url.redact_in(message))
};
let context = iter::successors(err.source(), |&err| err.source())
.map(|err| format!(" Caused by: {}", redact(&err.to_string())))
.join("\n");
let error = redact(&err.to_string());
debug!(
"Transient request failure for `{}`, retrying: {error}\n{context}",
url.map_or_else(|| "unknown URL".to_owned(), ToString::to_string)
);
}
}
}
retryable
}
}
pub struct RetryState {
retry_policy: ExponentialBackoff,
start_time: SystemTime,
total_retries: u32,
url: SafeUrl,
}
impl RetryState {
pub fn start(retry_policy: ExponentialBackoff, url: impl Into<SafeUrl>) -> Self {
Self {
retry_policy,
start_time: SystemTime::now(),
total_retries: 0,
url: url.into(),
}
}
pub(crate) fn total_retries(&self) -> u32 {
self.total_retries
}
pub(crate) fn duration(&self) -> Result<Duration, SystemTimeError> {
self.start_time.elapsed()
}
pub async fn send(
&mut self,
request: RequestBuilder<'_>,
) -> reqwest_middleware::Result<Response> {
let result = request.send().await;
self.record_request_retries(result.as_ref());
result
}
pub(crate) async fn handle_response<Payload, CallbackError, Callback>(
&mut self,
response: Response,
callback: Callback,
) -> Result<Payload, CallbackError>
where
Callback: AsyncFnOnce(Response, &mut Self) -> Result<Payload, CallbackError>,
{
self.record_request_retries(Ok(&response));
callback(response, self).await
}
fn record_request_retries(&mut self, result: Result<&Response, &reqwest_middleware::Error>) {
let retries = match result {
Ok(response) => response
.extensions()
.get::<reqwest_retry::RetryCount>()
.map_or(0, |retries| retries.value()),
Err(reqwest_middleware::Error::Middleware(err)) => {
match err.downcast_ref::<reqwest_retry::RetryError>() {
Some(reqwest_retry::RetryError::WithRetries { retries, .. }) => *retries,
Some(reqwest_retry::RetryError::Error(_)) | None => 0,
}
}
Err(reqwest_middleware::Error::Reqwest(_)) => 0,
};
self.total_retries += retries;
}
#[must_use]
pub fn should_retry(
&mut self,
err: &(dyn Error + 'static),
error_retries: u32,
) -> Option<Duration> {
self.total_retries += error_retries;
match retryable_on_request_failure(err) {
Some(Retryable::Transient) => {
let now = SystemTime::now();
let retry_decision = self
.retry_policy
.should_retry(self.start_time, self.total_retries);
if let reqwest_retry::RetryDecision::Retry { execute_after } = retry_decision {
let duration = execute_after
.duration_since(now)
.unwrap_or_else(|_| Duration::default());
self.total_retries += 1;
return Some(duration);
}
None
}
Some(Retryable::Fatal) | None => None,
}
}
pub async fn sleep_backoff(&self, duration: Duration) {
debug!(
"Transient failure while handling response from `{}`; retrying after {:.1}s...",
self.url,
duration.as_secs_f32(),
);
tokio::time::sleep(duration).await;
}
}
pub fn retryable_on_request_failure(err: &(dyn Error + 'static)) -> Option<Retryable> {
retryable_on_request_failure_inner(err, None)
}
fn retryable_on_request_failure_inner(
err: &(dyn Error + 'static),
method: Option<&Method>,
) -> Option<Retryable> {
if let Some((Some(status), Some(url))) = find_source::<WrappedReqwestError>(&err)
.map(|request_err| (request_err.status(), request_err.url()))
{
trace!(
"Considering retry of response HTTP {status} for `{url}`",
url = SafeUrl::from_url(url.clone())
);
} else if let Some(url) = request_error_url(err) {
trace!(
"Considering retry of error: {}",
url.redact_in(&format!("{err:?}"))
);
} else {
trace!("Considering retry of error: {err:?}");
}
let mut has_known_error = false;
let mut current_source = Some(err);
while let Some(source) = current_source {
let reqwest_err = if let Some(reqwest_err) = source.downcast_ref::<reqwest::Error>() {
Some(reqwest_err)
} else if let Some(reqwest_err) = source
.downcast_ref::<WrappedReqwestError>()
.and_then(|err| err.inner())
{
Some(reqwest_err)
} else if let Some(reqwest_middleware::Error::Reqwest(reqwest_err)) =
source.downcast_ref::<reqwest_middleware::Error>()
{
Some(reqwest_err)
} else {
None
};
if let Some(reqwest_err) = reqwest_err {
has_known_error = true;
if is_tls_certificate_error(reqwest_err) {
trace!("Fatal nested reqwest TLS certificate error");
return Some(Retryable::Fatal);
}
if default_on_request_error(reqwest_err) == Some(Retryable::Transient) {
trace!("Transient nested reqwest error");
return Some(Retryable::Transient);
}
if is_retryable_status_error(reqwest_err) {
trace!("Transient nested reqwest status code error");
return Some(Retryable::Transient);
}
if reqwest_err.is_request() && method.is_some_and(Method::is_safe) {
trace!("Transient unclassified reqwest request error for safe method");
return Some(Retryable::Transient);
}
trace!("Fatal nested reqwest error");
} else if source.downcast_ref::<h2::Error>().is_some() {
trace!("Transient nested h2 error");
return Some(Retryable::Transient);
} else if let Some(io_err) = source.downcast_ref::<io::Error>() {
has_known_error = true;
let retryable_io_err_kinds = [
io::ErrorKind::BrokenPipe,
io::ErrorKind::ConnectionAborted,
io::ErrorKind::ConnectionReset,
io::ErrorKind::InvalidData,
io::ErrorKind::TimedOut,
io::ErrorKind::UnexpectedEof,
];
if retryable_io_err_kinds.contains(&io_err.kind()) {
trace!("Transient IO error: `{}`", io_err.kind());
return Some(Retryable::Transient);
}
trace!(
"Fatal IO error `{}`, not a transient IO error kind",
io_err.kind()
);
}
current_source = source.source();
}
if !has_known_error {
trace!("Cannot retry error: neither an IO error nor a reqwest error");
}
None
}
pub trait RetriableError: std::error::Error + Sized + 'static {
fn should_try_next_url(&self) -> bool;
fn retries(&self) -> u32;
#[must_use]
fn into_retried(self, retries: u32, duration: Duration) -> Self;
}
fn is_retryable_status_error(reqwest_err: &reqwest::Error) -> bool {
let Some(status) = reqwest_err.status() else {
return false;
};
status.is_server_error()
|| status == StatusCode::REQUEST_TIMEOUT
|| status == StatusCode::TOO_MANY_REQUESTS
}
fn is_tls_certificate_error(reqwest_err: &reqwest::Error) -> bool {
let Some(rustls_error) = find_source::<RustlsError>(reqwest_err) else {
return false;
};
match rustls_error {
RustlsError::InvalidCertificate(_) | RustlsError::NoCertificatesPresented => true,
RustlsError::AlertReceived(alert) => matches!(
alert,
AlertDescription::AccessDenied
| AlertDescription::BadCertificate
| AlertDescription::BadCertificateHashValue
| AlertDescription::BadCertificateStatusResponse
| AlertDescription::CertificateExpired
| AlertDescription::CertificateRequired
| AlertDescription::CertificateRevoked
| AlertDescription::CertificateUnknown
| AlertDescription::CertificateUnobtainable
| AlertDescription::DecryptError
| AlertDescription::NoCertificate
| AlertDescription::UnknownCA
| AlertDescription::UnsupportedCertificate
),
_ => false,
}
}
fn request_error_url<'a>(err: &'a (dyn Error + 'static)) -> Option<&'a SafeUrl> {
iter::successors(Some(err), |&err| {
if let Some(io_error) = err.downcast_ref::<io::Error>()
&& let Some(inner) = io_error.get_ref()
{
Some(inner as &(dyn Error + 'static))
} else {
err.source()
}
})
.find_map(|err| {
err.downcast_ref::<reqwest::Error>()
.and_then(reqwest::Error::url)
.or_else(|| {
err.downcast_ref::<reqwest_middleware::Error>()
.and_then(reqwest_middleware::Error::url)
})
.or_else(|| {
err.downcast_ref::<WrappedReqwestError>()
.and_then(|err| err.url())
})
})
.map(SafeUrl::ref_cast)
}
fn find_source<E: Error + 'static>(orig: &dyn Error) -> Option<&E> {
let mut cause = orig.source();
while let Some(err) = cause {
if let Some(concrete_err) = err.downcast_ref() {
return Some(concrete_err);
}
if let Some(io_err) = err.downcast_ref::<io::Error>()
&& let Some(inner_err) = io_err.get_ref()
{
if let Some(concrete_err) = inner_err.downcast_ref() {
return Some(concrete_err);
}
cause = Some(inner_err);
continue;
}
cause = err.source();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use insta::assert_debug_snapshot;
use reqwest::Client;
use reqwest_middleware::ClientWithMiddleware;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tracing_test::traced_test;
use wiremock::matchers::path;
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::retryable_on_request_failure;
#[tokio::test]
async fn unclassified_request_errors_respect_method() -> Result<()> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let url: SafeUrl = format!("http://{}", listener.local_addr()?).parse()?;
let server = async {
let (stream, _) = listener.accept().await?;
let mut stream = BufReader::new(stream);
let mut request = String::new();
while stream.read_line(&mut request).await? != 0 {
if request.ends_with("\r\n\r\n") {
break;
}
}
stream
.get_mut()
.write_all(b"invalid HTTP response\r\n\r\n")
.await
};
let client = Client::new();
let (served, response) = tokio::join!(server, client.get(url.as_str()).send());
served?;
let error = response.expect_err("invalid response must fail");
assert!(error.is_request());
assert!(retryable_on_request_failure(&error).is_none());
let result = Err(reqwest_middleware::Error::Reqwest(error));
assert!(UvRetryableStrategy(Method::POST).handle(&result).is_none());
assert!(UvRetryableStrategy(Method::HEAD).handle(&result) == Some(Retryable::Transient));
Ok(())
}
#[tokio::test]
#[traced_test]
async fn retry_logs_redact_signed_urls() -> Result<()> {
let server = MockServer::start().await;
Mock::given(path("/wheel"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let response = Client::new()
.get(format!(
"{}/wheel?sig=azure-secret&X-Amz-Signature=aws-secret",
server.uri()
))
.send()
.await?;
assert!(matches!(
UvRetryableStrategy(Method::GET).handle(&Ok(response)),
Some(Retryable::Transient)
));
let response = Client::new()
.get(format!(
"{}/wheel?sig=azure-secret&X-Amz-Signature=aws-secret",
server.uri()
))
.send()
.await?;
let err = response
.error_for_status()
.expect_err("expected a 503 response");
assert!(matches!(
UvRetryableStrategy(Method::GET).handle(&Err(err.into())),
Some(Retryable::Transient)
));
let response = Client::new()
.get(format!(
"{}/wheel?sig=azure-secret&X-Amz-Signature=aws-secret",
server.uri()
))
.send()
.await?;
let err = response
.error_for_status()
.expect_err("expected a 503 response");
let err = reqwest_middleware::Error::middleware(reqwest_retry::RetryError::WithRetries {
retries: 1,
err: err.into(),
});
assert!(matches!(
UvRetryableStrategy(Method::GET).handle(&Err(err)),
Some(Retryable::Transient)
));
logs_assert(|lines| {
let logs = lines.join("\n");
assert!(logs.contains("Transient request failure"));
assert!(logs.contains("Considering retry"));
assert!(logs.contains("sig=****&X-Amz-Signature=****"));
assert!(!logs.contains("azure-secret"));
assert!(!logs.contains("aws-secret"));
Ok(())
});
Ok(())
}
#[tokio::test]
async fn retried_status_codes() -> Result<()> {
let server = MockServer::start().await;
let client = Client::default();
let middleware_client = ClientWithMiddleware::default();
let mut retried = Vec::new();
for status in 100..599 {
if StatusCode::from_u16(status)?.canonical_reason().is_none() && status != 420 {
continue;
}
Mock::given(path(format!("/{status}")))
.respond_with(ResponseTemplate::new(status))
.mount(&server)
.await;
let response = middleware_client
.get(format!("{}/{}", server.uri(), status))
.send()
.await;
let middleware_retry =
UvRetryableStrategy(Method::GET).handle(&response) == Some(Retryable::Transient);
let response = client
.get(format!("{}/{}", server.uri(), status))
.send()
.await?;
let uv_retry = match response.error_for_status() {
Ok(_) => false,
Err(err) => retryable_on_request_failure(&err) == Some(Retryable::Transient),
};
assert_eq!(middleware_retry, uv_retry);
if uv_retry {
retried.push(status);
}
}
assert_debug_snapshot!(retried, @"
[
100,
102,
103,
408,
429,
500,
501,
502,
503,
504,
505,
506,
507,
508,
510,
511,
]
");
Ok(())
}
}