mod classify;
mod scope;
use std::{error::Error as StdError, future::Ready, sync::Arc, time::Duration};
use http::{Request, Response};
use tower::retry::{
Policy,
budget::{Budget, TpsBudget},
};
pub(crate) use self::{
classify::{Action, Classifier, ClassifyFn, ReqRep},
scope::{ScopeFn, Scoped},
};
use crate::{Body, ClientResponseBody, error::BoxError, retry};
#[derive(Clone)]
pub(crate) struct RetryPolicy {
budget: Option<Arc<TpsBudget>>,
classifier: Classifier,
max_retries_per_request: u32,
retry_cnt: u32,
scope: Scoped,
}
impl RetryPolicy {
#[inline]
pub(crate) fn new(policy: retry::Policy) -> Self {
Self {
budget: policy
.budget
.map(|budget| Arc::new(TpsBudget::new(Duration::from_secs(10), 10, budget))),
classifier: policy.classifier,
max_retries_per_request: policy.max_retries_per_request,
retry_cnt: 0,
scope: policy.scope,
}
}
}
type Req = Request<Body>;
type Res = Response<ClientResponseBody>;
pub(crate) fn clone_http_request(req: &Req) -> Option<Req> {
let body = req.body().try_clone()?;
let mut new = http::Request::new(body);
*new.method_mut() = req.method().clone();
*new.uri_mut() = req.uri().clone();
*new.version_mut() = req.version();
*new.headers_mut() = req.headers().clone();
*new.extensions_mut() = req.extensions().clone();
Some(new)
}
impl Policy<Req, Res, BoxError> for RetryPolicy {
type Future = Ready<()>;
fn retry(&mut self, req: &mut Req, result: &mut Result<Res, BoxError>) -> Option<Self::Future> {
match self.classifier.classify(req, result) {
Action::Success => {
trace!(
"Request successful, no retry needed: {} {}",
req.method(),
req.uri()
);
if let Some(ref budget) = self.budget {
budget.deposit();
trace!("Token deposited back to retry budget");
}
None
}
Action::Retryable => {
trace!(
"Retrying request ({} attempts so far): {} {} - {}",
self.retry_cnt,
req.method(),
req.uri(),
match result {
Ok(res) => format!("HTTP {}", res.status()),
Err(e) => format!("Error: {}", e),
}
);
Some(std::future::ready(()))
}
}
}
fn clone_request(&mut self, req: &Req) -> Option<Req> {
if self.retry_cnt > 0 && !self.scope.applies_to(req) {
trace!("not in scope, not retrying");
return None;
}
if self.retry_cnt >= self.max_retries_per_request {
trace!("max_retries_per_request hit");
return None;
}
if !self.budget.as_ref().is_none_or(|b| b.withdraw()) {
debug!(
"Request is retryable but retry budget exhausted: {} {}",
req.method(),
req.uri()
);
return None;
}
self.retry_cnt += 1;
if let Some(cloned) = clone_http_request(req) {
Some(cloned)
} else {
if let Some(ref budget) = self.budget {
budget.deposit();
}
None
}
}
}
fn is_retryable_error(err: &(dyn StdError + 'static), method: &http::Method) -> bool {
let mut source = Some(err);
while let Some(err) = source {
#[cfg(feature = "http2")]
if let Some(h2_err) = err.downcast_ref::<http2::Error>() {
if h2_err.is_go_away()
&& h2_err.is_remote()
&& h2_err.reason() == Some(http2::Reason::NO_ERROR)
{
return true;
}
if h2_err.is_reset()
&& h2_err.is_remote()
&& h2_err.reason() == Some(http2::Reason::REFUSED_STREAM)
{
return true;
}
}
if err
.downcast_ref::<crate::Error>()
.is_some_and(|e| e.is_connect())
|| err
.downcast_ref::<crate::client::Error>()
.is_some_and(|e| e.is_connect())
|| matches!(
err.downcast_ref::<std::io::Error>().map(|e| e.kind()),
Some(std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::ConnectionAborted)
)
{
return true;
}
if method_is_idempotent(method) && (is_timeout_error(err) || is_connection_reset_error(err))
{
return true;
}
source = err.source();
}
false
}
fn method_is_idempotent(method: &http::Method) -> bool {
matches!(
*method,
http::Method::GET
| http::Method::HEAD
| http::Method::PUT
| http::Method::DELETE
| http::Method::OPTIONS
| http::Method::TRACE
)
}
fn is_timeout_error(err: &(dyn StdError + 'static)) -> bool {
err.is::<crate::error::TimedOut>()
|| err
.downcast_ref::<crate::Error>()
.is_some_and(|e| e.is_timeout())
|| err
.downcast_ref::<crate::client::CoreError>()
.is_some_and(|e| e.is_timeout())
|| err
.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::TimedOut)
}
fn is_connection_reset_error(err: &(dyn StdError + 'static)) -> bool {
err.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::ConnectionReset)
}
#[cfg(test)]
mod tests {
use std::error::Error as StdError;
use http::Request;
use http_body_util::BodyExt;
use tower::retry::Policy as _;
use super::{RetryPolicy, is_retryable_error};
use crate::{Body, retry};
fn make_retry_policy(max_retries: u32) -> RetryPolicy {
let policy = retry::Policy {
budget: Some(0.2),
classifier: crate::client::layer::retry::Classifier::Never,
max_retries_per_request: max_retries,
scope: crate::client::layer::retry::Scoped::Unscoped,
};
RetryPolicy::new(policy)
}
#[test]
fn budget_preserved_when_clone_fails() {
let mut policy = make_retry_policy(2);
let body: Body = http_body_util::Empty::new()
.map_err(|e| -> crate::error::BoxError { match e {} })
.boxed()
.into();
let req = Request::new(body);
let result = policy.clone_request(&req);
assert!(result.is_none(), "non-clonable body should return None");
let clonable_req = Request::new(Body::from("hello"));
let result2 = policy.clone_request(&clonable_req);
assert!(
result2.is_some(),
"budget should still have tokens after failed clone"
);
}
fn io_err(kind: std::io::ErrorKind) -> Box<dyn StdError + Send + Sync> {
Box::new(std::io::Error::new(kind, "synthetic io error"))
}
fn to_dyn<'a>(err: &'a (dyn StdError + 'static)) -> &'a (dyn StdError + 'static) {
err
}
#[test]
fn connect_failure_retryable_for_all_methods() {
let err = crate::Error::request(io_err(std::io::ErrorKind::ConnectionRefused));
assert!(is_retryable_error(to_dyn(&err), &http::Method::GET));
assert!(is_retryable_error(to_dyn(&err), &http::Method::POST));
let aborted = crate::Error::request(io_err(std::io::ErrorKind::ConnectionAborted));
assert!(is_retryable_error(to_dyn(&aborted), &http::Method::GET));
assert!(is_retryable_error(to_dyn(&aborted), &http::Method::POST));
}
#[test]
fn timeout_retryable_only_for_idempotent_methods() {
let err = crate::Error::request(io_err(std::io::ErrorKind::TimedOut));
assert!(is_retryable_error(to_dyn(&err), &http::Method::GET));
assert!(is_retryable_error(to_dyn(&err), &http::Method::DELETE));
assert!(!is_retryable_error(to_dyn(&err), &http::Method::POST));
assert!(!is_retryable_error(to_dyn(&err), &http::Method::PATCH));
}
#[test]
fn connection_reset_retryable_only_for_idempotent_methods() {
let err = crate::Error::request(io_err(std::io::ErrorKind::ConnectionReset));
assert!(is_retryable_error(to_dyn(&err), &http::Method::GET));
assert!(is_retryable_error(to_dyn(&err), &http::Method::PUT));
assert!(!is_retryable_error(to_dyn(&err), &http::Method::POST));
}
#[test]
fn timeout_detected_deep_in_source_chain() {
let inner = crate::Error::request(io_err(std::io::ErrorKind::TimedOut));
let wrapped = std::io::Error::other(inner);
assert!(is_retryable_error(
&wrapped as &(dyn StdError + 'static),
&http::Method::GET,
));
assert!(!is_retryable_error(
&wrapped as &(dyn StdError + 'static),
&http::Method::POST,
));
}
#[test]
fn public_error_request_timedout_retryable_for_idempotent_methods() {
let err = crate::Error::request(crate::error::TimedOut);
assert!(is_retryable_error(to_dyn(&err), &http::Method::GET));
assert!(is_retryable_error(to_dyn(&err), &http::Method::DELETE));
assert!(!is_retryable_error(to_dyn(&err), &http::Method::POST));
assert!(!is_retryable_error(to_dyn(&err), &http::Method::PATCH));
}
#[test]
fn unrelated_errors_are_not_retryable() {
let err = crate::Error::request(io_err(std::io::ErrorKind::Other));
assert!(!is_retryable_error(to_dyn(&err), &http::Method::GET));
assert!(!is_retryable_error(to_dyn(&err), &http::Method::POST));
}
}