use std::error::Error as StdError;
use std::sync::Arc;
use std::time::{Duration, Instant};
use futures::future::BoxFuture;
use reqwest::header::LOCATION;
use reqwest::{Request, Response, StatusCode};
use tracing::info;
use crate::backoff::{Backoff, BackoffConfig};
use crate::service::HttpService;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(
"Received redirect without LOCATION, this normally indicates an incorrectly configured region"
)]
BareRedirect,
#[error("Server error, body contains Error, with status {status}: {}", body.as_deref().unwrap_or("No Body"))]
Server {
status: StatusCode,
body: Option<String>,
},
#[error("Client error with status {status}: {}", body.as_deref().unwrap_or("No Body"))]
Client {
status: StatusCode,
body: Option<String>,
},
#[error(
"Error after {retries} retries in {elapsed:?}, max_retries:{max_retries}, retry_timeout:{retry_timeout:?}, source:{source}"
)]
Reqwest {
retries: usize,
max_retries: usize,
elapsed: Duration,
retry_timeout: Duration,
source: reqwest::Error,
},
#[error("Transport error: {source}")]
Transport {
source: Box<dyn StdError + Send + Sync + 'static>,
},
}
impl Error {
pub fn status(&self) -> Option<StatusCode> {
match self {
Self::BareRedirect => None,
Self::Server { status, .. } => Some(*status),
Self::Client { status, .. } => Some(*status),
Self::Reqwest { source, .. } => source.status(),
Self::Transport { .. } => None,
}
}
pub fn body(&self) -> Option<&str> {
match self {
Self::Client { body, .. } => body.as_deref(),
Self::Server { body, .. } => body.as_deref(),
Self::BareRedirect => None,
Self::Reqwest { .. } => None,
Self::Transport { .. } => None,
}
}
pub fn error(self) -> crate::Error {
match self.status() {
Some(StatusCode::NOT_FOUND) => crate::Error::NotFound {
source: Box::new(self),
},
Some(StatusCode::NOT_MODIFIED) => crate::Error::NotModified {
source: Box::new(self),
},
Some(StatusCode::PRECONDITION_FAILED) => crate::Error::Precondition {
source: Box::new(self),
},
Some(StatusCode::CONFLICT) => crate::Error::AlreadyExists {
source: Box::new(self),
},
Some(StatusCode::FORBIDDEN) => crate::Error::PermissionDenied {
source: Box::new(self),
},
Some(StatusCode::UNAUTHORIZED) => crate::Error::Unauthenticated {
source: Box::new(self),
},
_ => crate::Error::Generic {
source: Box::new(self),
},
}
}
}
impl From<Error> for std::io::Error {
fn from(err: Error) -> Self {
use std::io::ErrorKind;
match &err {
Error::Client {
status: StatusCode::NOT_FOUND,
..
} => Self::new(ErrorKind::NotFound, err),
Error::Client {
status: StatusCode::BAD_REQUEST,
..
} => Self::new(ErrorKind::InvalidInput, err),
Error::Client {
status: StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN,
..
} => Self::new(ErrorKind::PermissionDenied, err),
Error::Reqwest { source, .. } if source.is_timeout() => {
Self::new(ErrorKind::TimedOut, err)
}
Error::Reqwest { source, .. } if source.is_connect() => {
Self::new(ErrorKind::NotConnected, err)
}
_ => Self::other(err),
}
}
}
pub(crate) type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub backoff: BackoffConfig,
pub max_retries: usize,
pub retry_timeout: Duration,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
backoff: Default::default(),
max_retries: 10,
retry_timeout: Duration::from_secs(3 * 60),
}
}
}
pub(crate) struct RetryableRequest {
service: Arc<dyn HttpService>,
request: Request,
max_retries: usize,
retry_timeout: Duration,
backoff: Backoff,
sensitive: bool,
idempotent: Option<bool>,
}
impl RetryableRequest {
pub(crate) fn idempotent(self, idempotent: bool) -> Self {
Self {
idempotent: Some(idempotent),
..self
}
}
#[allow(unused)]
pub(crate) fn sensitive(self, sensitive: bool) -> Self {
Self { sensitive, ..self }
}
pub(crate) async fn send(self) -> Result<Response> {
let max_retries = self.max_retries;
let retry_timeout = self.retry_timeout;
let mut retries = 0;
let now = Instant::now();
let mut backoff = self.backoff;
let is_idempotent = self
.idempotent
.unwrap_or_else(|| self.request.method().is_safe());
let sanitize_err = move |e: reqwest::Error| match self.sensitive {
true => e.without_url(),
false => e,
};
loop {
let request = self
.request
.try_clone()
.expect("request body must be cloneable");
match self.service.call(request).await {
Ok(r) => match r.error_for_status_ref() {
Ok(_) if r.status().is_success() => {
return Ok(r);
}
Ok(r) if r.status() == StatusCode::NOT_MODIFIED => {
return Err(Error::Client {
body: None,
status: StatusCode::NOT_MODIFIED,
});
}
Ok(r) => {
let is_bare_redirect =
r.status().is_redirection() && !r.headers().contains_key(LOCATION);
return match is_bare_redirect {
true => Err(Error::BareRedirect),
false => Err(Error::Client {
body: None,
status: r.status(),
}),
};
}
Err(e) => {
let e = sanitize_err(e);
let status = r.status();
if retries == max_retries
|| now.elapsed() > retry_timeout
|| !status.is_server_error()
{
return Err(match status.is_client_error() {
true => match r.text().await {
Ok(body) => Error::Client {
body: Some(body).filter(|b| !b.is_empty()),
status,
},
Err(e) => Error::Reqwest {
retries,
max_retries,
elapsed: now.elapsed(),
retry_timeout,
source: e,
},
},
false => Error::Reqwest {
retries,
max_retries,
elapsed: now.elapsed(),
retry_timeout,
source: e,
},
});
}
let sleep = backoff.next();
retries += 1;
info!(
"Encountered server error, backing off for {} seconds, retry {} of {}: {}",
sleep.as_secs_f32(),
retries,
max_retries,
e,
);
tokio::time::sleep(sleep).await;
}
},
Err(crate::Error::ReqwestError(e)) => {
let e = sanitize_err(e);
let mut do_retry = false;
if e.is_connect()
|| e.is_body()
|| (e.is_request() && !e.is_timeout())
|| (is_idempotent && e.is_timeout())
{
do_retry = true
} else {
let mut source = e.source();
while let Some(e) = source {
if let Some(e) = e.downcast_ref::<hyper::Error>() {
do_retry = e.is_closed()
|| e.is_incomplete_message()
|| e.is_body_write_aborted()
|| (is_idempotent && e.is_timeout());
break;
}
if let Some(e) = e.downcast_ref::<std::io::Error>() {
if e.kind() == std::io::ErrorKind::TimedOut {
do_retry = is_idempotent;
} else {
do_retry = matches!(
e.kind(),
std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::BrokenPipe
| std::io::ErrorKind::UnexpectedEof
);
}
break;
}
source = e.source();
}
}
if retries == max_retries || now.elapsed() > retry_timeout || !do_retry {
return Err(Error::Reqwest {
retries,
max_retries,
elapsed: now.elapsed(),
retry_timeout,
source: e,
});
}
let sleep = backoff.next();
retries += 1;
info!(
"Encountered transport error backing off for {} seconds, retry {} of {}: {}",
sleep.as_secs_f32(),
retries,
max_retries,
e,
);
tokio::time::sleep(sleep).await;
}
Err(other) => {
return Err(Error::Transport {
source: Box::new(other),
});
}
}
}
}
}
pub(crate) trait RetryExt {
fn retryable(self, config: &RetryConfig, service: Arc<dyn HttpService>) -> RetryableRequest;
fn send_retry(
self,
config: &RetryConfig,
service: Arc<dyn HttpService>,
) -> BoxFuture<'static, Result<Response>>;
}
impl RetryExt for reqwest::RequestBuilder {
fn retryable(self, config: &RetryConfig, service: Arc<dyn HttpService>) -> RetryableRequest {
let (_client, request) = self.build_split();
let request = request.expect("request must be valid");
RetryableRequest {
service,
request,
max_retries: config.max_retries,
retry_timeout: config.retry_timeout,
backoff: Backoff::new(&config.backoff),
sensitive: false,
idempotent: None,
}
}
fn send_retry(
self,
config: &RetryConfig,
service: Arc<dyn HttpService>,
) -> BoxFuture<'static, Result<Response>> {
let request = self.retryable(config, service);
Box::pin(async move { request.send().await })
}
}
#[cfg(test)]
mod tests {
use super::RetryConfig;
use crate::retry::{Error, RetryExt};
use crate::service::ReqwestService;
use reqwest::{Client, Method, StatusCode};
use std::sync::Arc;
use std::time::Duration;
#[tokio::test]
async fn test_retry() {
let mut server = mockito::Server::new_async().await;
let retry = RetryConfig {
backoff: Default::default(),
max_retries: 2,
retry_timeout: Duration::from_secs(1000),
};
let client = Client::builder()
.timeout(Duration::from_millis(100))
.build()
.unwrap();
let service = Arc::new(ReqwestService::new(client.clone()));
let server_url = server.url();
let do_request = || {
client
.request(Method::GET, &server_url)
.send_retry(&retry, service.clone())
};
let _mock1 = server
.mock("GET", "/")
.with_status(200)
.with_body("")
.create_async()
.await;
let r = do_request().await.unwrap();
assert_eq!(r.status(), StatusCode::OK);
let _mock2 = server
.mock("GET", "/")
.with_status(400)
.with_body("cupcakes")
.create_async()
.await;
let e = do_request().await.unwrap_err();
assert_eq!(e.status().unwrap(), StatusCode::BAD_REQUEST);
assert_eq!(e.body(), Some("cupcakes"));
assert_eq!(
e.to_string(),
"Client error with status 400 Bad Request: cupcakes"
);
let _mock3 = server
.mock("GET", "/")
.with_status(400)
.with_body("")
.create_async()
.await;
let e = do_request().await.unwrap_err();
assert_eq!(e.status().unwrap(), StatusCode::BAD_REQUEST);
assert_eq!(e.body(), None);
assert_eq!(
e.to_string(),
"Client error with status 400 Bad Request: No Body"
);
let _mock4 = server
.mock("GET", "/")
.with_status(502)
.with_body("")
.create_async()
.await;
let _mock5 = server
.mock("GET", "/")
.with_status(200)
.with_body("")
.create_async()
.await;
let r = do_request().await.unwrap();
assert_eq!(r.status(), StatusCode::OK);
let _mock6 = server
.mock("GET", "/")
.with_status(204)
.with_body("")
.create_async()
.await;
let r = do_request().await.unwrap();
assert_eq!(r.status(), StatusCode::NO_CONTENT);
let _mock7 = server
.mock("GET", "/")
.with_status(302)
.with_header("location", "/foo")
.with_body("")
.create_async()
.await;
let _mock8 = server
.mock("GET", "/foo")
.with_status(200)
.with_body("")
.create_async()
.await;
let r = do_request().await.unwrap();
assert_eq!(r.status(), StatusCode::OK);
assert_eq!(r.url().path(), "/foo");
let _mock_redirect = server
.mock("GET", "/")
.with_status(302)
.with_body("")
.create_async()
.await;
let e = do_request().await.unwrap_err();
assert!(matches!(e, Error::BareRedirect));
assert_eq!(
e.to_string(),
"Received redirect without LOCATION, this normally indicates an incorrectly configured region"
);
let _timeout_mock1 = server
.mock("GET", "/")
.with_status(200)
.with_body("")
.create_async()
.await;
let _timeout_mock2 = server
.mock("GET", "/")
.with_status(200)
.with_body("")
.create_async()
.await;
do_request().await.unwrap();
let sensitive_url = format!("{}/SENSITIVE", server_url);
let _sensitive_mock = server
.mock("GET", "/SENSITIVE")
.with_status(502)
.with_body("ignored")
.create_async()
.await;
let res = client
.request(Method::GET, &sensitive_url)
.send_retry(&retry, service.clone())
.await;
let err = res.unwrap_err().to_string();
assert!(err.contains("SENSITIVE"), "{err}");
let _sensitive_mock2 = server
.mock("GET", "/SENSITIVE")
.with_status(502)
.with_body("ignored")
.create_async()
.await;
let req = client
.request(Method::GET, &sensitive_url)
.retryable(&retry, service.clone())
.sensitive(true);
let err = req.send().await.unwrap_err().to_string();
assert!(!err.contains("SENSITIVE"), "{err}");
}
#[tokio::test]
async fn test_retry_exhaustion_returns_last_server_error() {
let mut server = mockito::Server::new_async().await;
let retry = RetryConfig {
backoff: crate::backoff::BackoffConfig {
init_backoff: Duration::from_millis(1),
max_backoff: Duration::from_millis(2),
base: 2.,
},
max_retries: 2,
retry_timeout: Duration::from_secs(1000),
};
let client = Client::builder()
.timeout(Duration::from_millis(100))
.build()
.unwrap();
let service = Arc::new(ReqwestService::new(client.clone()));
let server_url = server.url();
let mock = server
.mock("GET", "/")
.with_status(502)
.with_body("upstream boom")
.expect(3)
.create_async()
.await;
let err = client
.request(Method::GET, &server_url)
.send_retry(&retry, service.clone())
.await
.unwrap_err();
assert!(matches!(err, Error::Reqwest { .. }), "{err:?}");
assert_eq!(err.status(), Some(StatusCode::BAD_GATEWAY));
mock.assert_async().await;
}
#[tokio::test]
async fn test_no_retries_returns_immediately() {
let mut server = mockito::Server::new_async().await;
let retry = RetryConfig {
backoff: Default::default(),
max_retries: 0,
retry_timeout: Duration::from_secs(1000),
};
let client = Client::builder().build().unwrap();
let service = Arc::new(ReqwestService::new(client.clone()));
let server_url = server.url();
let mock = server
.mock("GET", "/")
.with_status(503)
.with_body("")
.expect(1)
.create_async()
.await;
let err = client
.request(Method::GET, &server_url)
.send_retry(&retry, service.clone())
.await
.unwrap_err();
assert_eq!(err.status(), Some(StatusCode::SERVICE_UNAVAILABLE));
mock.assert_async().await;
}
}