use std::future::Future;
use aspero_core::{Error, ResiliencePolicy};
use tokio::time::Instant;
pub type Jitter = fn() -> f64;
pub async fn with_retries<T, F, Fut>(
policy: &ResiliencePolicy,
jitter: Jitter,
mut op: F,
) -> Result<T, Error>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, Error>>,
{
let deadline = Instant::now() + policy.deadline;
let mut retry = 0;
loop {
let error = match tokio::time::timeout_at(deadline, op()).await {
Ok(Ok(value)) => return Ok(value),
Ok(Err(e)) => e,
Err(_elapsed) => return Err(Error::Timeout),
};
if !error.is_retryable() || retry >= policy.max_retries {
return Err(error);
}
let mut delay = policy.backoff(retry, jitter());
if let Error::RateLimited {
retry_after: Some(after),
} = &error
{
delay = delay.max(*after);
}
if Instant::now() + delay >= deadline {
return Err(error);
}
tracing::info!(
target: "aspero::retry",
retry = retry + 1, delay_ms = delay.as_millis() as u64, error = %error,
"backing off"
);
tokio::time::sleep(delay).await;
retry += 1;
}
}
pub fn random_jitter() -> f64 {
fastrand::f64()
}
#[cfg(test)]
pub fn full_jitter() -> f64 {
1.0
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use super::*;
fn policy() -> ResiliencePolicy {
let mut p = ResiliencePolicy::default();
p.max_retries = 3;
p.backoff_base = Duration::from_millis(100);
p.backoff_cap = Duration::from_secs(10);
p.deadline = Duration::from_secs(60);
p
}
fn scripted(
errors: Vec<Error>,
) -> (
impl FnMut() -> std::future::Ready<Result<u32, Error>>,
std::sync::Arc<AtomicU32>,
) {
let calls = std::sync::Arc::new(AtomicU32::new(0));
let counter = calls.clone();
let op = move || {
let n = counter.fetch_add(1, Ordering::SeqCst) as usize;
std::future::ready(match errors.get(n) {
Some(e) => Err(e.clone()),
None => Ok(n as u32),
})
};
(op, calls)
}
fn server_error() -> Error {
Error::Server {
status: 503,
message: None,
}
}
#[tokio::test(start_paused = true)]
async fn retries_transient_errors_with_doubling_backoff() {
let (op, calls) = scripted(vec![server_error(), Error::Timeout]);
let start = Instant::now();
assert_eq!(with_retries(&policy(), full_jitter, op).await.unwrap(), 2);
assert_eq!(calls.load(Ordering::SeqCst), 3);
assert_eq!(start.elapsed(), Duration::from_millis(100 + 200));
}
#[tokio::test(start_paused = true)]
async fn retry_after_stretches_the_backoff() {
let limited = Error::RateLimited {
retry_after: Some(Duration::from_secs(2)),
};
let (op, _) = scripted(vec![limited]);
let start = Instant::now();
with_retries(&policy(), full_jitter, op).await.unwrap();
assert_eq!(start.elapsed(), Duration::from_secs(2));
}
#[tokio::test(start_paused = true)]
async fn non_retryable_errors_return_at_once() {
let (op, calls) = scripted(vec![Error::QuotaExhausted { resets_at: None }]);
let err = with_retries(&policy(), full_jitter, op).await.unwrap_err();
assert!(matches!(err, Error::QuotaExhausted { .. }));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test(start_paused = true)]
async fn gives_up_after_max_retries_with_the_last_error() {
let (op, calls) = scripted(vec![server_error(); 10]);
let err = with_retries(&policy(), full_jitter, op).await.unwrap_err();
assert!(matches!(err, Error::Server { status: 503, .. }));
assert_eq!(calls.load(Ordering::SeqCst), 4);
}
#[tokio::test(start_paused = true)]
async fn stops_before_a_sleep_would_pass_the_deadline() {
let mut p = policy();
p.deadline = Duration::from_millis(250);
let (op, calls) = scripted(vec![server_error(); 10]);
let start = Instant::now();
with_retries(&p, full_jitter, op).await.unwrap_err();
assert_eq!(calls.load(Ordering::SeqCst), 2);
assert_eq!(start.elapsed(), Duration::from_millis(100));
}
#[tokio::test(start_paused = true)]
async fn a_hung_attempt_times_out_at_the_deadline() {
let mut p = policy();
p.deadline = Duration::from_secs(1);
let err = with_retries(&p, full_jitter, || {
std::future::pending::<Result<(), Error>>()
})
.await
.unwrap_err();
assert!(matches!(err, Error::Timeout));
}
}