use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct RetryPolicy {
pub max_attempts: u32,
pub base_delay: Duration,
pub max_delay: Duration,
pub deadline: Duration,
}
impl RetryPolicy {
#[must_use]
pub fn new(max_attempts: u32, base_delay: Duration, deadline: Duration) -> Self {
Self {
max_attempts: max_attempts.max(1),
base_delay,
max_delay: Duration::from_secs(30),
deadline,
}
}
#[must_use]
pub fn with_max_delay(mut self, max_delay: Duration) -> Self {
self.max_delay = max_delay;
self
}
#[must_use]
pub fn delay(&self, attempt: u32, jitter_entropy: u64) -> Duration {
let backoff_nanos = capped_backoff_nanos(
self.base_delay.as_nanos(),
self.max_delay.as_nanos(),
attempt,
);
if backoff_nanos == 0 {
return Duration::ZERO;
}
let jitter_range = backoff_nanos.saturating_add(1);
let jitter = u128::from(jitter_entropy)
.checked_rem(jitter_range)
.unwrap_or(0);
duration_from_nanos(backoff_nanos.saturating_sub(jitter))
}
#[must_use]
pub fn deadline_exceeded(&self, elapsed: Duration) -> bool {
elapsed >= self.deadline
}
#[must_use]
pub fn should_retry(&self, failures: u32, elapsed: Duration) -> bool {
failures < self.max_attempts.max(1) && !self.deadline_exceeded(elapsed)
}
}
fn capped_backoff_nanos(base_nanos: u128, cap_nanos: u128, attempt: u32) -> u128 {
let base_nanos = base_nanos.min(cap_nanos);
if base_nanos == 0 || base_nanos == cap_nanos {
return base_nanos;
}
if attempt >= u128::BITS {
return cap_nanos;
}
let shift = attempt;
if base_nanos > (cap_nanos >> shift) {
cap_nanos
} else {
base_nanos << shift
}
}
fn duration_from_nanos(nanos: u128) -> Duration {
let seconds = u64::try_from(nanos.checked_div(1_000_000_000).unwrap_or(0)).unwrap_or(u64::MAX);
let subsecond_nanos = u32::try_from(nanos.checked_rem(1_000_000_000).unwrap_or(0)).unwrap_or(0);
Duration::new(seconds, subsecond_nanos)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backoff_doubles_and_caps() {
let policy = RetryPolicy::new(5, Duration::from_millis(100), Duration::from_secs(60));
assert_eq!(policy.delay(0, 0), Duration::from_millis(100));
assert_eq!(policy.delay(1, 0), Duration::from_millis(200));
assert_eq!(policy.delay(2, 0), Duration::from_millis(400));
let capped = RetryPolicy::new(5, Duration::from_secs(20), Duration::from_secs(600))
.with_max_delay(Duration::from_secs(25));
assert_eq!(capped.delay(3, 0), Duration::from_secs(25));
}
#[test]
fn jitter_only_shrinks() {
let policy = RetryPolicy::new(4, Duration::from_millis(200), Duration::from_secs(60));
for entropy in [0u64, 1, 7, 1_000, u64::MAX] {
let delay = policy.delay(2, entropy);
assert!(
delay <= Duration::from_millis(800),
"jitter grew backoff: {delay:?}"
);
}
assert_eq!(policy.delay(2, 0), Duration::from_millis(800));
}
#[test]
fn zero_attempts_normalises_to_one_try() {
let policy = RetryPolicy::new(0, Duration::from_millis(10), Duration::from_secs(1));
assert_eq!(policy.max_attempts, 1);
assert!(policy.should_retry(0, Duration::ZERO));
assert!(!policy.should_retry(1, Duration::ZERO));
}
#[test]
fn huge_attempt_counts_cannot_overflow_or_hang() {
let policy = RetryPolicy::new(u32::MAX, Duration::from_millis(1), Duration::MAX);
assert_eq!(
policy.delay(u32::MAX, 0),
Duration::from_secs(30),
"a huge retry index reaches the configured cap"
);
}
#[test]
fn deadline_gates_retries() {
let policy = RetryPolicy::new(10, Duration::from_millis(50), Duration::from_secs(5));
assert!(policy.should_retry(9, Duration::from_secs(4)));
assert!(!policy.should_retry(9, Duration::from_secs(5)));
assert!(!policy.should_retry(10, Duration::ZERO));
}
#[test]
fn zero_base_delay_stays_zero() {
let policy = RetryPolicy::new(3, Duration::ZERO, Duration::from_secs(1));
assert_eq!(
policy.delay(4, 12345),
Duration::ZERO,
"zero base delay remains zero after any retry index"
);
}
fn reference_backoff_nanos(base: Duration, cap: Duration, attempt: u32) -> u128 {
let cap_nanos = cap.as_nanos();
let mut delay_nanos = base.as_nanos().min(cap_nanos);
for _ in 0..attempt.min(u128::BITS) {
delay_nanos = delay_nanos.saturating_mul(2).min(cap_nanos);
if delay_nanos == cap_nanos {
break;
}
}
delay_nanos
}
#[test]
fn public_delay_matches_reference_at_attempt_and_cap_boundaries() {
let attempts = [0, 1, 30, 31, 32, 35, u32::MAX];
let cases = [
(Duration::ZERO, Duration::from_nanos(1)),
(Duration::from_nanos(1), Duration::ZERO),
(Duration::from_nanos(1), Duration::from_nanos(1)),
(Duration::from_nanos(1), Duration::from_nanos(2)),
(Duration::from_millis(100), Duration::from_millis(50)),
(Duration::from_millis(100), Duration::from_millis(100)),
(Duration::from_millis(100), Duration::from_millis(200)),
(Duration::from_millis(100), Duration::from_secs(30)),
];
for (base, cap) in cases {
let policy = RetryPolicy::new(1, base, Duration::MAX).with_max_delay(cap);
for attempt in attempts {
let expected = duration_from_nanos(reference_backoff_nanos(base, cap, attempt));
assert_eq!(
policy.delay(attempt, 0),
expected,
"attempt {attempt} must use exact capped doubling from {base:?} to {cap:?}"
);
}
}
}
#[test]
fn jitter_matches_wide_reference_across_duration_boundaries() {
let durations = [
Duration::from_nanos(1),
Duration::from_nanos(u64::MAX),
Duration::new(u64::MAX, 999_999_999),
Duration::MAX,
];
let entropies = [0, 1, u64::MAX];
for duration in durations {
for entropy in entropies {
let policy = RetryPolicy::new(1, duration, Duration::MAX).with_max_delay(duration);
let backoff_nanos = reference_backoff_nanos(duration, duration, 0);
let expected = backoff_nanos - (u128::from(entropy) % (backoff_nanos + 1));
assert_eq!(
policy.delay(0, entropy),
duration_from_nanos(expected),
"entropy {entropy} must follow the inclusive wide-range formula for {duration:?}"
);
}
}
}
#[test]
fn effective_attempt_floor_and_deadline_equality_are_consistent() {
let mut policy = RetryPolicy::new(0, Duration::ZERO, Duration::from_nanos(1));
assert_eq!(
policy.max_attempts, 1,
"construction normalizes zero attempts"
);
assert!(
policy.should_retry(0, Duration::ZERO),
"one initial attempt is allowed before the deadline"
);
assert!(
!policy.should_retry(0, Duration::from_nanos(1)),
"deadline equality refuses even the initial attempt"
);
policy.max_attempts = 0;
assert!(
policy.should_retry(0, Duration::ZERO),
"direct mutation to zero retains the effective one-attempt floor"
);
assert!(
!policy.should_retry(1, Duration::ZERO),
"the effective one-attempt floor refuses work after one failure"
);
assert!(
!RetryPolicy::new(1, Duration::ZERO, Duration::ZERO).should_retry(0, Duration::ZERO),
"a zero deadline refuses at equality"
);
}
#[test]
fn delay_work_is_bounded_independently_of_the_attempt_value() {
let policy = RetryPolicy::new(u32::MAX, Duration::from_nanos(1), Duration::MAX);
let repeats = 100_000_u64;
let mut timings = [(0_u32, std::time::Duration::ZERO); 4];
for (index, attempt) in [0_u32, 31, 1_000, u32::MAX].into_iter().enumerate() {
let start = std::time::Instant::now();
let mut observed = Duration::ZERO;
for step in 0..repeats {
observed = policy.delay(attempt, step);
}
assert_eq!(
observed,
policy.delay(attempt, repeats.saturating_sub(1)),
"the timed loop must finish on the last computed value"
);
timings[index] = (attempt, start.elapsed());
}
let baseline = timings[0].1.as_nanos();
for (attempt, elapsed) in timings {
assert!(
elapsed.as_nanos() <= baseline.saturating_mul(4).saturating_add(1_000_000),
"attempt {attempt} took {elapsed:?} against {baseline}ns at attempt 0; \
the work must not scale with the numeric attempt value"
);
}
}
#[test]
fn the_ordinary_sequence_is_unchanged_for_an_existing_consumer() {
let policy = RetryPolicy::new(6, Duration::from_millis(100), Duration::from_secs(60));
let observed: Vec<Duration> = (0..6).map(|attempt| policy.delay(attempt, 0)).collect();
let expected = [
Duration::from_millis(100),
Duration::from_millis(200),
Duration::from_millis(400),
Duration::from_millis(800),
Duration::from_millis(1_600),
Duration::from_millis(3_200),
];
assert_eq!(
observed, expected,
"the ordinary doubling sequence must be unchanged"
);
}
}