#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
use std::time::Duration;
use crate::entropy::Entropy;
pub const DEFAULT_HTTP_MAX_BACKOFF_MS: u64 = 20_000;
pub const DEFAULT_JOB_MAX_BACKOFF_MS: u64 = 3_600_000;
pub const RETRY_AFTER_SLACK: Duration = Duration::from_secs(5);
#[must_use]
pub const fn ceiling_ms(base_ms: u64, cap_ms: u64, retry: u32) -> u64 {
let factor = if retry >= u64::BITS {
u64::MAX
} else {
1_u64 << retry
};
let raw = base_ms.saturating_mul(factor);
if raw < cap_ms { raw } else { cap_ms }
}
#[must_use]
pub fn full_jitter_ms(entropy: &dyn Entropy, base_ms: u64, cap_ms: u64, retry: u32) -> u64 {
let ceiling = ceiling_ms(base_ms, cap_ms, retry);
let draw = entropy.next_u64();
ceiling.checked_add(1).map_or(draw, |span| draw % span)
}
#[must_use]
pub fn full_jitter(entropy: &dyn Entropy, base: Duration, cap: Duration, retry: u32) -> Duration {
Duration::from_millis(full_jitter_ms(
entropy,
duration_ms(base),
duration_ms(cap),
retry,
))
}
#[must_use]
pub fn retry_after_wait(hint: Duration, backoff: Duration) -> Duration {
backoff.saturating_add(hint.min(RETRY_AFTER_SLACK))
}
fn duration_ms(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entropy::SeededEntropy;
use proptest::prelude::*;
#[derive(Debug)]
struct Fixed(u64);
impl Entropy for Fixed {
fn next_u64(&self) -> u64 {
self.0
}
fn fill_bytes(&self, dest: &mut [u8]) {
dest.fill(0);
}
}
#[test]
fn ceiling_doubles_then_caps() {
assert_eq!(ceiling_ms(100, 20_000, 0), 100);
assert_eq!(ceiling_ms(100, 20_000, 1), 200);
assert_eq!(ceiling_ms(100, 20_000, 7), 12_800);
assert_eq!(ceiling_ms(100, 20_000, 8), 20_000);
assert_eq!(ceiling_ms(100, 20_000, u32::MAX), 20_000);
}
#[test]
fn ceiling_saturates_without_a_cap() {
assert_eq!(ceiling_ms(u64::MAX, u64::MAX, 3), u64::MAX);
assert_eq!(ceiling_ms(1, u64::MAX, 63), 1 << 63);
assert_eq!(ceiling_ms(1, u64::MAX, 64), u64::MAX);
assert_eq!(ceiling_ms(0, u64::MAX, 64), 0);
}
#[test]
fn full_jitter_reaches_both_ends() {
assert_eq!(full_jitter_ms(&Fixed(0), 100, 1_000, 0), 0);
assert_eq!(full_jitter_ms(&Fixed(100), 100, 1_000, 0), 100);
assert_eq!(full_jitter_ms(&Fixed(101), 100, 1_000, 0), 0);
assert_eq!(
full_jitter_ms(&Fixed(u64::MAX), u64::MAX, u64::MAX, 0),
u64::MAX
);
}
#[test]
fn full_jitter_is_zero_for_a_zero_base() {
assert_eq!(full_jitter_ms(&Fixed(77), 0, 1_000, 5), 0);
}
#[test]
fn full_jitter_spreads_one_retry() {
let entropy = SeededEntropy::new(0x3054);
let delays: std::collections::HashSet<u64> = (0..32)
.map(|_| full_jitter_ms(&entropy, 1_000, 60_000, 0))
.collect();
assert!(
delays.len() > 16,
"32 draws gave only {} values",
delays.len()
);
}
#[test]
fn full_jitter_replays_from_a_seed() {
let a = SeededEntropy::new(7);
let b = SeededEntropy::new(7);
for retry in 0..16 {
assert_eq!(
full_jitter_ms(&a, 250, 3_600_000, retry),
full_jitter_ms(&b, 250, 3_600_000, retry)
);
}
}
#[test]
fn full_jitter_duration_matches_ms() {
let delay = full_jitter(
&Fixed(150),
Duration::from_millis(100),
Duration::from_secs(20),
1,
);
assert_eq!(delay, Duration::from_millis(150));
let huge = full_jitter(&Fixed(u64::MAX), Duration::MAX, Duration::MAX, 0);
assert_eq!(huge, Duration::from_millis(u64::MAX));
}
#[test]
fn retry_after_wait_adds_the_hint_to_the_backoff() {
let backoff = Duration::from_millis(80);
assert_eq!(retry_after_wait(Duration::ZERO, backoff), backoff);
assert_eq!(
retry_after_wait(Duration::from_secs(2), backoff),
Duration::from_millis(2_080)
);
assert_eq!(
retry_after_wait(Duration::from_secs(3_600), backoff),
backoff + RETRY_AFTER_SLACK
);
assert_eq!(
retry_after_wait(Duration::MAX, Duration::MAX),
Duration::MAX
);
}
proptest! {
#[test]
fn delay_is_within_zero_and_cap(
seed in any::<u64>(),
base in any::<u64>(),
cap in any::<u64>(),
retry in any::<u32>(),
) {
let entropy = SeededEntropy::new(seed);
let delay = full_jitter_ms(&entropy, base, cap, retry);
prop_assert!(delay <= cap);
prop_assert!(delay <= ceiling_ms(base, cap, retry));
}
#[test]
fn ceiling_is_monotonic(base in any::<u64>(), cap in any::<u64>(), retry in 0_u32..200) {
prop_assert!(ceiling_ms(base, cap, retry) <= ceiling_ms(base, cap, retry + 1));
}
#[test]
fn duration_delay_is_within_cap(
seed in any::<u64>(),
base_ms in any::<u64>(),
cap_ms in any::<u64>(),
retry in any::<u32>(),
) {
let entropy = SeededEntropy::new(seed);
let cap = Duration::from_millis(cap_ms);
let delay = full_jitter(&entropy, Duration::from_millis(base_ms), cap, retry);
prop_assert!(delay <= cap);
}
#[test]
fn hinted_wait_is_in_window(hint_ms in any::<u64>(), backoff_ms in any::<u64>()) {
let backoff = Duration::from_millis(backoff_ms);
let hint = Duration::from_millis(hint_ms);
let wait = retry_after_wait(hint, backoff);
prop_assert!(wait >= backoff);
prop_assert!(wait >= hint.min(RETRY_AFTER_SLACK));
prop_assert!(wait <= backoff.saturating_add(RETRY_AFTER_SLACK));
}
}
}
#[cfg(kani)]
mod kani_proofs {
use super::*;
#[derive(Debug)]
struct AnyDraw(u64);
impl Entropy for AnyDraw {
fn next_u64(&self) -> u64 {
self.0
}
fn fill_bytes(&self, dest: &mut [u8]) {
dest.fill(0);
}
}
#[kani::proof]
fn ceiling_is_capped_and_exact_below_the_cap() {
let base: u64 = kani::any();
let cap: u64 = kani::any();
let retry: u32 = kani::any();
let ceiling = ceiling_ms(base, cap, retry);
assert!(ceiling <= cap);
if retry < u64::BITS {
if let Some(raw) = base.checked_mul(1_u64 << retry) {
assert_eq!(ceiling, raw.min(cap));
}
}
}
#[kani::proof]
fn ceiling_is_monotonic() {
let base: u64 = kani::any();
let cap: u64 = kani::any();
let retry: u32 = kani::any();
kani::assume(retry < u32::MAX);
assert!(ceiling_ms(base, cap, retry) <= ceiling_ms(base, cap, retry + 1));
}
#[kani::proof]
fn full_jitter_is_within_the_ceiling() {
let base: u64 = kani::any();
let cap: u64 = kani::any();
let retry: u32 = kani::any();
let entropy = AnyDraw(kani::any());
let delay = full_jitter_ms(&entropy, base, cap, retry);
assert!(delay <= ceiling_ms(base, cap, retry));
assert!(delay <= cap);
kani::cover!(delay == 0);
kani::cover!(delay == cap && cap > 0);
}
#[kani::proof]
fn retry_after_wait_is_in_its_window() {
let backoff = Duration::new(kani::any(), kani::any::<u32>() % 1_000_000_000);
let hint = Duration::new(kani::any(), kani::any::<u32>() % 1_000_000_000);
let wait = retry_after_wait(hint, backoff);
assert!(wait >= backoff);
assert!(wait <= backoff.saturating_add(RETRY_AFTER_SLACK));
assert!(wait >= hint.min(RETRY_AFTER_SLACK));
}
}