Skip to main content

cufflink_types/
backoff.rs

1//! Retry delays for subscriptions, jobs, the outbox and webhook deliveries.
2
3use std::time::Duration;
4
5const JITTER_PERCENT: u64 = 20;
6const MIN_DELAY: Duration = Duration::from_secs(1);
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct Backoff {
10    steps: Vec<Duration>,
11    base: Duration,
12    max: Duration,
13}
14
15impl Backoff {
16    /// The n-th retry waits `steps[min(n, len) - 1]`; with no steps every
17    /// retry waits the minimum delay.
18    pub fn explicit(steps: Vec<Duration>) -> Self {
19        Self {
20            steps,
21            base: MIN_DELAY,
22            max: MIN_DELAY,
23        }
24    }
25
26    /// The n-th retry waits `base * 2^(n - 1)`, capped at `max`.
27    pub fn exponential(base: Duration, max: Duration) -> Self {
28        Self {
29            steps: Vec::new(),
30            base,
31            max: max.max(base),
32        }
33    }
34
35    /// The delay before retry `attempt` (1 for the first), spread ±20% by a
36    /// jitter that depends only on the seed and the attempt, and never
37    /// below one second.
38    pub fn delay(&self, attempt: u32, jitter_seed: u64) -> Duration {
39        let attempt = attempt.max(1);
40        jittered(self.nominal(attempt), attempt, jitter_seed).max(MIN_DELAY)
41    }
42
43    /// The longest a retry waits, before its jitter.
44    pub fn longest(&self) -> Duration {
45        self.steps.iter().copied().max().unwrap_or(self.max)
46    }
47
48    fn nominal(&self, attempt: u32) -> Duration {
49        let index = usize::try_from(attempt - 1).unwrap_or(usize::MAX);
50        match self.steps.last() {
51            Some(last) => self.steps.get(index).unwrap_or(last).to_owned(),
52            None => 1u32
53                .checked_shl(attempt - 1)
54                .and_then(|factor| self.base.checked_mul(factor))
55                .map_or(self.max, |delay| delay.min(self.max)),
56        }
57    }
58}
59
60fn jittered(nominal: Duration, attempt: u32, seed: u64) -> Duration {
61    let span = 2 * JITTER_PERCENT + 1;
62    let percent = 100 - JITTER_PERCENT + mix(seed, attempt) % span;
63    let millis = u128::from(percent) * nominal.as_millis() / 100;
64    Duration::from_millis(u64::try_from(millis).unwrap_or(u64::MAX))
65}
66
67/// SplitMix64 over the seed and the attempt.
68fn mix(seed: u64, attempt: u32) -> u64 {
69    let z = seed
70        .wrapping_add(u64::from(attempt).wrapping_mul(0x9E37_79B9_7F4A_7C15))
71        .wrapping_add(0x9E37_79B9_7F4A_7C15);
72    let z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
73    let z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
74    z ^ (z >> 31)
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    const fn secs(n: u64) -> Duration {
82        Duration::from_secs(n)
83    }
84
85    fn within_jitter(delay: Duration, nominal: Duration) -> bool {
86        let (low, high) = (nominal.mul_f64(0.8), nominal.mul_f64(1.2));
87        (low.max(MIN_DELAY)..=high.max(MIN_DELAY)).contains(&delay)
88    }
89
90    #[test]
91    fn explicit_steps_clamp_to_the_last() {
92        let backoff = Backoff::explicit(vec![secs(10), secs(30)]);
93        for (attempt, nominal) in [(1, 10), (2, 30), (3, 30), (50, 30), (u32::MAX, 30)] {
94            let delay = backoff.delay(attempt, 7);
95            assert!(within_jitter(delay, secs(nominal)), "{attempt}: {delay:?}");
96        }
97    }
98
99    #[test]
100    fn attempt_zero_is_the_first_retry() {
101        let backoff = Backoff::explicit(vec![secs(10), secs(30)]);
102        assert_eq!(backoff.delay(0, 7), backoff.delay(1, 7));
103    }
104
105    #[test]
106    fn exponential_doubles_and_caps() {
107        let backoff = Backoff::exponential(secs(5), secs(300));
108        for (attempt, nominal) in [
109            (1, 5),
110            (2, 10),
111            (3, 20),
112            (6, 160),
113            (7, 300),
114            (8, 300),
115            (33, 300),
116            (64, 300),
117            (u32::MAX, 300),
118        ] {
119            let delay = backoff.delay(attempt, 99);
120            assert!(within_jitter(delay, secs(nominal)), "{attempt}: {delay:?}");
121        }
122    }
123
124    #[test]
125    fn a_cap_below_the_base_is_the_base() {
126        let backoff = Backoff::exponential(secs(30), secs(10));
127        assert!(within_jitter(backoff.delay(1, 1), secs(30)));
128        assert!(within_jitter(backoff.delay(9, 1), secs(30)));
129    }
130
131    #[test]
132    fn the_longest_wait_is_the_cap_or_the_longest_step() {
133        for (backoff, longest) in [
134            (Backoff::exponential(secs(5), secs(300)), secs(300)),
135            (Backoff::exponential(secs(30), secs(10)), secs(30)),
136            (
137                Backoff::explicit(vec![secs(10), secs(30), secs(20)]),
138                secs(30),
139            ),
140            (Backoff::explicit(vec![]), MIN_DELAY),
141        ] {
142            assert_eq!(backoff.longest(), longest, "{backoff:?}");
143            let jittered = longest.mul_f64(1.2).max(MIN_DELAY);
144            for attempt in (1..=40).chain([u32::MAX]) {
145                assert!(backoff.delay(attempt, 3) <= jittered, "{backoff:?}");
146            }
147        }
148    }
149
150    #[test]
151    fn jitter_is_deterministic_bounded_and_spread() {
152        let backoff = Backoff::exponential(secs(100), secs(100));
153        let delays: Vec<Duration> = (0..500).map(|seed| backoff.delay(1, seed)).collect();
154        assert!(delays.iter().all(|d| within_jitter(*d, secs(100))));
155        assert_eq!(delays[3], backoff.delay(1, 3));
156        let distinct: std::collections::BTreeSet<_> = delays.iter().collect();
157        assert!(distinct.len() > 20, "{}", distinct.len());
158        assert!(delays.iter().any(|d| *d < secs(100)));
159        assert!(delays.iter().any(|d| *d > secs(100)));
160    }
161
162    #[test]
163    fn a_delay_is_never_below_one_second() {
164        for backoff in [
165            Backoff::explicit(vec![]),
166            Backoff::explicit(vec![Duration::ZERO]),
167            Backoff::explicit(vec![Duration::from_millis(900)]),
168            Backoff::exponential(Duration::ZERO, Duration::ZERO),
169            Backoff::exponential(Duration::from_millis(10), Duration::from_millis(20)),
170        ] {
171            for seed in 0..50 {
172                assert!(backoff.delay(1, seed) >= MIN_DELAY, "{backoff:?}");
173            }
174        }
175    }
176
177    #[test]
178    fn huge_durations_do_not_overflow() {
179        let backoff = Backoff::exponential(Duration::MAX, Duration::MAX);
180        assert!(backoff.delay(40, 5) >= MIN_DELAY);
181        let backoff = Backoff::explicit(vec![Duration::MAX]);
182        assert!(backoff.delay(1, 5) >= MIN_DELAY);
183    }
184}