use std::time::Duration;
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_retries: u32,
pub base_delay: Duration,
pub max_delay: Duration,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
base_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(10),
}
}
}
impl RetryConfig {
pub fn none() -> Self {
Self {
max_retries: 0,
base_delay: Duration::from_millis(0),
max_delay: Duration::from_millis(0),
}
}
}
pub fn is_retryable_status(status: u16) -> bool {
status == 429 || (500..=599).contains(&status)
}
pub fn is_retryable_reqwest_err(e: &reqwest::Error) -> bool {
e.is_timeout() || e.is_connect() || e.is_request()
}
pub fn exp_backoff_with_jitter(attempt: u32, config: &RetryConfig) -> Duration {
if attempt == 0 {
return Duration::ZERO;
}
let exp = (attempt - 1).min(20); let scale = 1u64 << exp;
let base_ms = config.base_delay.as_millis() as u64;
let scaled_ms = base_ms.saturating_mul(scale);
let max_ms = config.max_delay.as_millis() as u64;
let cap_ms = scaled_ms.min(max_ms);
if cap_ms == 0 {
return Duration::ZERO;
}
let mut buf = [0u8; 4];
if getrandom::getrandom(&mut buf).is_err() {
return Duration::from_millis(cap_ms / 2);
}
let r = u32::from_le_bytes(buf);
let jittered = (r as u64) % (cap_ms + 1);
Duration::from_millis(jittered)
}
pub fn parse_retry_after(header: Option<&str>, max_delay: Duration) -> Option<Duration> {
let raw = header?.trim();
let secs: u64 = raw.parse().ok()?;
let d = Duration::from_secs(secs);
Some(d.min(max_delay))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retryable_status_classifies_correctly() {
assert!(is_retryable_status(429));
assert!(is_retryable_status(500));
assert!(is_retryable_status(502));
assert!(is_retryable_status(503));
assert!(is_retryable_status(504));
assert!(is_retryable_status(599));
assert!(!is_retryable_status(200));
assert!(!is_retryable_status(400));
assert!(!is_retryable_status(401));
assert!(!is_retryable_status(403));
assert!(!is_retryable_status(404));
assert!(!is_retryable_status(408)); assert!(!is_retryable_status(422));
}
#[test]
fn backoff_zero_attempt_returns_zero() {
let d = exp_backoff_with_jitter(0, &RetryConfig::default());
assert_eq!(d, Duration::ZERO);
}
#[test]
fn backoff_caps_at_max_delay() {
let cfg = RetryConfig {
max_retries: 100,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(2),
};
for _ in 0..50 {
let d = exp_backoff_with_jitter(20, &cfg);
assert!(d <= cfg.max_delay, "got {d:?}, cap {:?}", cfg.max_delay);
}
}
#[test]
fn backoff_grows_on_average() {
let cfg = RetryConfig {
max_retries: 10,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(60),
};
let mean = |attempt: u32, n: u32| -> u128 {
let total: u128 = (0..n)
.map(|_| exp_backoff_with_jitter(attempt, &cfg).as_millis())
.sum();
total / n as u128
};
let m1 = mean(1, 200);
let m4 = mean(4, 200);
assert!(
m4 > m1,
"mean attempt=4 ({m4}ms) should exceed attempt=1 ({m1}ms)"
);
}
#[test]
fn backoff_zero_max_returns_zero() {
let cfg = RetryConfig::none();
let d = exp_backoff_with_jitter(1, &cfg);
assert_eq!(d, Duration::ZERO);
}
#[test]
fn parse_retry_after_seconds_form() {
let cap = Duration::from_secs(30);
assert_eq!(parse_retry_after(Some("5"), cap), Some(Duration::from_secs(5)));
assert_eq!(parse_retry_after(Some(" 10 "), cap), Some(Duration::from_secs(10)));
}
#[test]
fn parse_retry_after_caps_at_max() {
let cap = Duration::from_secs(10);
assert_eq!(parse_retry_after(Some("999999"), cap), Some(cap));
}
#[test]
fn parse_retry_after_returns_none_on_garbage() {
let cap = Duration::from_secs(10);
assert_eq!(parse_retry_after(None, cap), None);
assert_eq!(
parse_retry_after(Some("Wed, 21 Oct 2015 07:28:00 GMT"), cap),
None
);
assert_eq!(parse_retry_after(Some("not a number"), cap), None);
}
#[test]
fn config_default_sane() {
let c = RetryConfig::default();
assert_eq!(c.max_retries, 3);
assert_eq!(c.base_delay, Duration::from_millis(500));
assert_eq!(c.max_delay, Duration::from_secs(10));
}
#[test]
fn config_none_disables_retries() {
let c = RetryConfig::none();
assert_eq!(c.max_retries, 0);
}
}