use std::time::Duration;
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_retries: u32,
pub initial_backoff: Duration,
pub max_backoff: Duration,
pub max_retry_after: Duration,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 2,
initial_backoff: Duration::from_millis(500),
max_backoff: Duration::from_secs(8),
max_retry_after: Duration::from_secs(60),
}
}
}
impl RetryConfig {
pub fn disabled() -> Self {
Self {
max_retries: 0,
..Self::default()
}
}
pub fn is_retryable_status(status: u16) -> bool {
matches!(status, 408 | 409 | 429) || status >= 500
}
pub fn delay(&self, attempt: u32, retry_after: Option<Duration>) -> Duration {
if let Some(ra) = retry_after {
if ra <= self.max_retry_after {
return ra;
}
}
let exp = self
.initial_backoff
.saturating_mul(2u32.saturating_pow(attempt))
.min(self.max_backoff);
let r = {
use std::hash::{BuildHasher, Hasher};
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
h.write_u32(attempt);
(h.finish() % 1000) as f64 / 1000.0
};
exp.mul_f64(r.mul_add(0.5, 0.5))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retryable_statuses() {
for s in [408u16, 409, 429, 500, 502, 503, 529] {
assert!(RetryConfig::is_retryable_status(s), "{s} should retry");
}
for s in [200u16, 400, 401, 403, 404, 413] {
assert!(!RetryConfig::is_retryable_status(s), "{s} should not");
}
}
#[test]
fn delay_grows_and_caps() {
let cfg = RetryConfig::default();
let d0 = cfg.delay(0, None);
assert!(d0 >= Duration::from_millis(250) && d0 < Duration::from_millis(500));
let d10 = cfg.delay(10, None);
assert!(d10 <= cfg.max_backoff);
}
#[test]
fn retry_after_honoured_within_bounds() {
let cfg = RetryConfig::default();
assert_eq!(
cfg.delay(0, Some(Duration::from_secs(3))),
Duration::from_secs(3)
);
assert!(cfg.delay(0, Some(Duration::from_secs(600))) < Duration::from_secs(1));
}
#[test]
fn disabled_never_retries() {
assert_eq!(RetryConfig::disabled().max_retries, 0);
}
}