use std::{future::Future, time::Duration};
#[derive(Debug)]
pub enum RetryableError<E> {
RateLimited {
after: Duration,
error: E,
},
Fatal(E),
}
pub async fn retry_rate_limited<T, E, F, Fut>(mut max_retries: u32, mut attempt: F) -> Result<T, E>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, RetryableError<E>>>,
{
loop {
match attempt().await {
Ok(value) => return Ok(value),
Err(RetryableError::Fatal(error)) => return Err(error),
Err(RetryableError::RateLimited { after, error }) => {
if max_retries == 0 {
return Err(error);
}
max_retries -= 1;
tokio::time::sleep(after).await;
}
}
}
}
#[cfg(test)]
mod tests {
use std::{
sync::atomic::{AtomicU32, Ordering},
time::Instant,
};
use super::{RetryableError, retry_rate_limited};
#[tokio::test]
async fn retries_once_after_rate_limit_then_succeeds() {
let attempts = AtomicU32::new(0);
let started = Instant::now();
let result: Result<&str, &str> = retry_rate_limited(3, || {
let n = attempts.fetch_add(1, Ordering::SeqCst);
async move {
if n == 0 {
Err(RetryableError::RateLimited {
after: std::time::Duration::from_millis(30),
error: "rate limited",
})
} else {
Ok("ok")
}
}
})
.await;
assert_eq!(result, Ok("ok"));
assert_eq!(attempts.load(Ordering::SeqCst), 2);
assert!(
started.elapsed() >= std::time::Duration::from_millis(30),
"must actually wait out the signaled backoff before retrying"
);
}
#[tokio::test]
async fn fatal_error_is_not_retried() {
let attempts = AtomicU32::new(0);
let result: Result<&str, &str> = retry_rate_limited(3, || {
attempts.fetch_add(1, Ordering::SeqCst);
async { Err(RetryableError::Fatal("boom")) }
})
.await;
assert_eq!(result, Err("boom"));
assert_eq!(attempts.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn exhausting_retries_surfaces_the_last_rate_limit_error() {
let attempts = AtomicU32::new(0);
let result: Result<&str, &str> = retry_rate_limited(2, || {
attempts.fetch_add(1, Ordering::SeqCst);
async {
Err(RetryableError::RateLimited {
after: std::time::Duration::from_millis(1),
error: "still limited",
})
}
})
.await;
assert_eq!(result, Err("still limited"));
assert_eq!(attempts.load(Ordering::SeqCst), 3);
}
}