use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use crate::error::TransferError;
#[derive(Debug, Clone, Copy)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub base: Duration,
pub max_delay: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 10,
base: Duration::from_millis(500),
max_delay: Duration::from_secs(30),
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Decision {
Retry {
delay: Duration,
attempt: u32,
},
GiveUp,
}
impl RetryPolicy {
pub fn decide(&self, err: &TransferError, attempts_made: u32) -> Decision {
if !err.is_retryable() || attempts_made >= self.max_attempts {
return Decision::GiveUp;
}
let delay = err
.retry_after()
.map(|d| d.min(self.max_delay))
.unwrap_or_else(|| self.backoff(attempts_made));
Decision::Retry {
delay,
attempt: attempts_made + 1,
}
}
fn backoff(&self, attempts_made: u32) -> Duration {
let exp = attempts_made.min(20);
let ceiling = self
.base
.saturating_mul(1u32.checked_shl(exp).unwrap_or(u32::MAX))
.min(self.max_delay);
let ceil_ms = ceiling.as_millis() as u64;
if ceil_ms == 0 {
return Duration::ZERO;
}
let floor_ms = ceil_ms / 2;
Duration::from_millis(floor_ms + jitter(ceil_ms - floor_ms + 1))
}
}
fn jitter(modulo: u64) -> u64 {
static STATE: AtomicU64 = AtomicU64::new(0);
let mut x = STATE.load(Ordering::Relaxed);
if x == 0 {
x = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9E3779B97F4A7C15)
| 1;
}
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
STATE.store(x, Ordering::Relaxed);
x.wrapping_mul(0x2545F4914F6CDD1D) % modulo.max(1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gives_up_on_permanent_errors() {
let p = RetryPolicy::default();
let err = TransferError::Status {
status: 404,
retry_after: None,
};
assert_eq!(p.decide(&err, 0), Decision::GiveUp);
}
#[test]
fn gives_up_after_max_attempts() {
let p = RetryPolicy {
max_attempts: 3,
..Default::default()
};
let err = TransferError::Network("reset".into());
assert!(matches!(p.decide(&err, 2), Decision::Retry { .. }));
assert_eq!(p.decide(&err, 3), Decision::GiveUp);
}
#[test]
fn delay_grows_and_is_capped() {
let p = RetryPolicy {
max_attempts: 30,
base: Duration::from_millis(100),
max_delay: Duration::from_secs(5),
};
let err = TransferError::Network("reset".into());
let mut prev = Duration::ZERO;
for n in 0..4 {
let Decision::Retry { delay, attempt } = p.decide(&err, n) else {
panic!("expected retry");
};
assert_eq!(attempt, n + 1);
assert!(delay >= prev, "delay {delay:?} < prev {prev:?}");
prev = delay;
}
for n in 10..20 {
let Decision::Retry { delay, .. } = p.decide(&err, n) else {
panic!("expected retry");
};
assert!(delay <= p.max_delay);
}
}
#[test]
fn honours_retry_after() {
let p = RetryPolicy::default();
let err = TransferError::Status {
status: 429,
retry_after: Some(Duration::from_secs(7)),
};
assert_eq!(
p.decide(&err, 0),
Decision::Retry {
delay: Duration::from_secs(7),
attempt: 1
}
);
}
#[test]
fn clamps_absurd_retry_after() {
let p = RetryPolicy::default();
let err = TransferError::Status {
status: 503,
retry_after: Some(Duration::from_secs(86_400)),
};
let Decision::Retry { delay, .. } = p.decide(&err, 0) else {
panic!("expected retry");
};
assert_eq!(delay, p.max_delay);
}
#[test]
fn jitter_spreads() {
let values: Vec<u64> = (0..50).map(|_| jitter(1000)).collect();
let distinct: std::collections::HashSet<_> = values.iter().collect();
assert!(distinct.len() > 20, "jitter is not spreading: {distinct:?}");
assert!(values.iter().all(|v| *v < 1000));
}
}