Skip to main content

mailrs_backoff/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5use std::time::Duration;
6
7/// Jitter policy applied to each delay sample.
8///
9/// Following AWS Architecture Blog's "Exponential Backoff and Jitter"
10/// taxonomy:
11///
12/// - **None** — deterministic exponential; bad at scale because all
13///   clients retry at the same instant ("thundering herd")
14/// - **Equal** — half the delay is fixed, half is uniformly random;
15///   bounded but smoothed
16/// - **Full** — delay is uniformly random in `[0, base]`; AWS's
17///   recommended default. Maximum spread.
18///
19/// See <https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/>.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Jitter {
22    /// No jitter — deterministic delay schedule.
23    None,
24    /// Half fixed, half random: `delay = base/2 + uniform(0, base/2)`.
25    Equal,
26    /// Fully random: `delay = uniform(0, base)`.
27    Full,
28}
29
30/// Exponential-backoff configuration.
31///
32/// `delay(attempt)` returns `min(initial × multiplier^attempt, max)`,
33/// then applies the configured [`Jitter`] policy.
34///
35/// All fields are `pub` so you can construct + tune directly, or use
36/// one of the preset constructors ([`Backoff::smtp_outbound`],
37/// [`Backoff::auth_lockout`], etc.).
38#[derive(Debug, Clone, Copy)]
39pub struct Backoff {
40    /// Delay for `attempt = 0`.
41    pub initial: Duration,
42    /// Geometric growth factor between attempts. Common values: 2.0
43    /// (doubling) or 1.5 (gentler ramp).
44    pub multiplier: f64,
45    /// Hard ceiling on delay — once `initial × multiplier^attempt`
46    /// would exceed `max`, return `max`.
47    pub max: Duration,
48    /// Jitter policy applied to the computed delay.
49    pub jitter: Jitter,
50}
51
52impl Default for Backoff {
53    /// Default: 1 second initial, 2× growth, 1 hour cap, Full jitter.
54    /// Reasonable for generic HTTP/RPC retry loops; pick a preset for
55    /// other use cases.
56    fn default() -> Self {
57        Self {
58            initial: Duration::from_secs(1),
59            multiplier: 2.0,
60            max: Duration::from_secs(3600),
61            jitter: Jitter::Full,
62        }
63    }
64}
65
66impl Backoff {
67    /// Construct with explicit values. Use the preset constructors
68    /// below for common shapes.
69    pub fn new(initial: Duration, multiplier: f64, max: Duration, jitter: Jitter) -> Self {
70        Self {
71            initial,
72            multiplier,
73            max,
74            jitter,
75        }
76    }
77
78    /// Preset for SMTP outbound delivery retries: 60s initial, 2.5×
79    /// growth, 8-hour cap, Full jitter. Mirrors the legacy
80    /// `mailrs-outbound-queue` schedule shape.
81    pub fn smtp_outbound() -> Self {
82        Self {
83            initial: Duration::from_secs(60),
84            multiplier: 2.5,
85            max: Duration::from_secs(8 * 3600),
86            jitter: Jitter::Full,
87        }
88    }
89
90    /// Preset for failed-auth lockout: 30min initial, 2× growth,
91    /// 24-hour cap, None jitter (lockouts are deterministic by design —
92    /// you want every offender to see exactly the same penalty).
93    pub fn auth_lockout() -> Self {
94        Self {
95            initial: Duration::from_secs(30 * 60),
96            multiplier: 2.0,
97            max: Duration::from_secs(24 * 3600),
98            jitter: Jitter::None,
99        }
100    }
101
102    /// Preset for webhook delivery retries: 60s initial, 2× growth,
103    /// 6-hour cap, Equal jitter. Smoother than Full jitter so subscriber
104    /// endpoints see a less spiky retry pattern.
105    pub fn webhook() -> Self {
106        Self {
107            initial: Duration::from_secs(60),
108            multiplier: 2.0,
109            max: Duration::from_secs(6 * 3600),
110            jitter: Jitter::Equal,
111        }
112    }
113
114    /// Base delay for `attempt` BEFORE jitter is applied. Useful when
115    /// you want to log "scheduled delay" without the random part.
116    pub fn base_delay(&self, attempt: u32) -> Duration {
117        // Early bail for very-high attempts. multiplier^attempt for
118        // attempt > 64 with multiplier >= 1.0 is astronomically larger
119        // than any realistic max. Skip the float math + the i32 cast
120        // (which would wrap for attempt > i32::MAX).
121        if attempt > 64 && self.multiplier >= 1.0 {
122            return self.max;
123        }
124        // For attempt <= 64 the cast to i32 is always safe.
125        let initial_ns = self.initial.as_nanos() as f64;
126        let max_ns = self.max.as_nanos() as f64;
127        let factor = self.multiplier.powi(attempt as i32);
128        let raw = initial_ns * factor;
129        let clamped = raw.min(max_ns).max(0.0);
130        Duration::from_nanos(clamped as u64)
131    }
132
133    /// Compute the delay for `attempt`, applying the configured jitter.
134    /// `seed` is a caller-supplied source of randomness — usually
135    /// derived from a fresh `rand::random::<u64>()` or
136    /// `std::time::Instant::now().elapsed().as_nanos()`. The crate
137    /// itself has no RNG dependency.
138    ///
139    /// For [`Jitter::None`] the seed is ignored. For Equal/Full it
140    /// drives a deterministic mapping (same seed → same delay), so
141    /// tests can be reproducible.
142    pub fn delay(&self, attempt: u32, seed: u64) -> Duration {
143        let base = self.base_delay(attempt);
144        match self.jitter {
145            Jitter::None => base,
146            Jitter::Equal => {
147                // half fixed + half random uniform [0, base/2]
148                let half_ns = base.as_nanos() as u64 / 2;
149                let random_part = scale_random(seed, half_ns);
150                Duration::from_nanos(half_ns + random_part)
151            }
152            Jitter::Full => {
153                let base_ns = base.as_nanos() as u64;
154                let random = scale_random(seed, base_ns);
155                Duration::from_nanos(random)
156            }
157        }
158    }
159
160    /// Convenience: should this attempt give up?
161    /// Returns `true` if `attempt >= max_attempts`.
162    pub fn should_give_up(attempt: u32, max_attempts: u32) -> bool {
163        attempt >= max_attempts
164    }
165}
166
167/// Map a u64 seed to a uniform sample in `[0, ceiling)`.
168/// `ceiling == 0` returns 0 (avoids division by zero).
169#[inline]
170fn scale_random(seed: u64, ceiling: u64) -> u64 {
171    if ceiling == 0 {
172        return 0;
173    }
174    // SplitMix64 step — fast, reasonable distribution. Not crypto.
175    let mut x = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
176    x ^= x >> 30;
177    x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9);
178    x ^= x >> 27;
179    x = x.wrapping_mul(0x94D0_49BB_1331_11EB);
180    x ^= x >> 31;
181    x % ceiling
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn default_construction() {
190        let b = Backoff::default();
191        assert_eq!(b.initial, Duration::from_secs(1));
192        assert_eq!(b.multiplier, 2.0);
193        assert_eq!(b.max, Duration::from_secs(3600));
194        assert_eq!(b.jitter, Jitter::Full);
195    }
196
197    #[test]
198    fn base_delay_grows_exponentially() {
199        let b = Backoff {
200            initial: Duration::from_secs(1),
201            multiplier: 2.0,
202            max: Duration::from_secs(3600),
203            jitter: Jitter::None,
204        };
205        assert_eq!(b.base_delay(0), Duration::from_secs(1));
206        assert_eq!(b.base_delay(1), Duration::from_secs(2));
207        assert_eq!(b.base_delay(2), Duration::from_secs(4));
208        assert_eq!(b.base_delay(3), Duration::from_secs(8));
209        assert_eq!(b.base_delay(10), Duration::from_secs(1024));
210    }
211
212    #[test]
213    fn base_delay_caps_at_max() {
214        let b = Backoff {
215            initial: Duration::from_secs(60),
216            multiplier: 2.0,
217            max: Duration::from_secs(3600),
218            jitter: Jitter::None,
219        };
220        // 60 × 2^7 = 7680 > 3600 → cap at 3600
221        assert_eq!(b.base_delay(7), Duration::from_secs(3600));
222        assert_eq!(b.base_delay(100), Duration::from_secs(3600));
223        assert_eq!(b.base_delay(u32::MAX), Duration::from_secs(3600));
224    }
225
226    #[test]
227    fn jitter_none_is_deterministic() {
228        let b = Backoff {
229            initial: Duration::from_secs(60),
230            multiplier: 2.0,
231            max: Duration::from_secs(3600),
232            jitter: Jitter::None,
233        };
234        // Two calls with different seeds → same result.
235        assert_eq!(b.delay(3, 0), b.delay(3, 999_999));
236        assert_eq!(b.delay(3, 0), b.base_delay(3));
237    }
238
239    #[test]
240    fn jitter_equal_returns_at_least_half_base() {
241        let b = Backoff {
242            initial: Duration::from_secs(100),
243            multiplier: 2.0,
244            max: Duration::from_secs(10_000),
245            jitter: Jitter::Equal,
246        };
247        let base = b.base_delay(2);
248        for seed in 0..100u64 {
249            let d = b.delay(2, seed);
250            assert!(d >= base / 2, "seed {seed}: d={d:?} >= base/2 {:?}", base / 2);
251            assert!(d <= base, "seed {seed}: d={d:?} <= base {base:?}");
252        }
253    }
254
255    #[test]
256    fn jitter_full_returns_in_zero_to_base() {
257        let b = Backoff {
258            initial: Duration::from_secs(100),
259            multiplier: 2.0,
260            max: Duration::from_secs(10_000),
261            jitter: Jitter::Full,
262        };
263        let base = b.base_delay(2);
264        for seed in 0..100u64 {
265            let d = b.delay(2, seed);
266            assert!(d < base, "seed {seed}: d={d:?} < base {base:?}");
267        }
268    }
269
270    #[test]
271    fn jitter_deterministic_with_same_seed() {
272        let b = Backoff::smtp_outbound();
273        // Same seed should always produce the same delay.
274        assert_eq!(b.delay(3, 42), b.delay(3, 42));
275        assert_eq!(b.delay(0, 12345), b.delay(0, 12345));
276    }
277
278    #[test]
279    fn smtp_outbound_preset() {
280        let b = Backoff::smtp_outbound();
281        assert_eq!(b.initial, Duration::from_secs(60));
282        assert_eq!(b.multiplier, 2.5);
283        assert_eq!(b.max, Duration::from_secs(8 * 3600));
284        assert_eq!(b.jitter, Jitter::Full);
285    }
286
287    #[test]
288    fn auth_lockout_preset() {
289        let b = Backoff::auth_lockout();
290        assert_eq!(b.initial, Duration::from_secs(30 * 60));
291        assert_eq!(b.multiplier, 2.0);
292        assert_eq!(b.max, Duration::from_secs(24 * 3600));
293        assert_eq!(b.jitter, Jitter::None);
294    }
295
296    #[test]
297    fn webhook_preset() {
298        let b = Backoff::webhook();
299        assert_eq!(b.initial, Duration::from_secs(60));
300        assert_eq!(b.multiplier, 2.0);
301        assert_eq!(b.max, Duration::from_secs(6 * 3600));
302        assert_eq!(b.jitter, Jitter::Equal);
303    }
304
305    #[test]
306    fn should_give_up_at_max() {
307        assert!(Backoff::should_give_up(5, 5));
308        assert!(Backoff::should_give_up(10, 5));
309        assert!(!Backoff::should_give_up(0, 5));
310        assert!(!Backoff::should_give_up(4, 5));
311    }
312
313    #[test]
314    fn should_give_up_zero_max_immediate() {
315        assert!(Backoff::should_give_up(0, 0));
316    }
317
318    #[test]
319    fn should_give_up_max_at_u32_boundary() {
320        assert!(!Backoff::should_give_up(u32::MAX - 1, u32::MAX));
321        assert!(Backoff::should_give_up(u32::MAX, u32::MAX));
322    }
323
324    #[test]
325    fn base_delay_attempt_zero_is_initial() {
326        let b = Backoff::smtp_outbound();
327        assert_eq!(b.base_delay(0), b.initial);
328    }
329
330    #[test]
331    fn multiplier_below_one_decays() {
332        // multiplier=0.5 → delays decrease (unusual but should not panic)
333        let b = Backoff {
334            initial: Duration::from_secs(100),
335            multiplier: 0.5,
336            max: Duration::from_secs(3600),
337            jitter: Jitter::None,
338        };
339        assert_eq!(b.base_delay(0), Duration::from_secs(100));
340        assert_eq!(b.base_delay(1), Duration::from_secs(50));
341        assert_eq!(b.base_delay(2), Duration::from_secs(25));
342    }
343
344    #[test]
345    fn jitter_full_with_zero_base_returns_zero() {
346        // attempt that yields zero base (initial=0)
347        let b = Backoff {
348            initial: Duration::ZERO,
349            multiplier: 2.0,
350            max: Duration::from_secs(3600),
351            jitter: Jitter::Full,
352        };
353        assert_eq!(b.delay(5, 42), Duration::ZERO);
354    }
355
356    #[test]
357    fn scale_random_zero_ceiling_returns_zero() {
358        assert_eq!(scale_random(123, 0), 0);
359        assert_eq!(scale_random(u64::MAX, 0), 0);
360    }
361
362    #[test]
363    fn scale_random_distribution_spread() {
364        // SplitMix64 should produce well-distributed results.
365        // Sample 1000 seeds, check we hit at least 90% of bucket coverage.
366        let buckets = 10;
367        let mut hits = [false; 10];
368        for seed in 0..1000u64 {
369            let v = scale_random(seed, buckets);
370            hits[v as usize] = true;
371        }
372        let coverage = hits.iter().filter(|h| **h).count();
373        assert!(coverage >= 9, "only hit {coverage}/{buckets} buckets");
374    }
375
376    #[test]
377    fn delay_caps_under_jitter() {
378        // Even with jitter, delay should never exceed max.
379        let b = Backoff::smtp_outbound();
380        for attempt in 0..30u32 {
381            for seed in [0u64, 1, 42, 100, 12345, u64::MAX] {
382                let d = b.delay(attempt, seed);
383                assert!(d <= b.max, "attempt={attempt} seed={seed} d={d:?} > max={:?}", b.max);
384            }
385        }
386    }
387
388    #[test]
389    fn very_high_attempt_doesnt_overflow() {
390        let b = Backoff::smtp_outbound();
391        // attempt=100 → multiplier^100 is astronomical, but cap saves us
392        let d = b.delay(100, 42);
393        assert!(d <= b.max);
394    }
395
396    #[test]
397    fn clone_and_copy_work() {
398        let a = Backoff::webhook();
399        let b = a;
400        assert_eq!(a.initial, b.initial);
401        assert_eq!(a.multiplier, b.multiplier);
402        let c = a;
403        assert_eq!(c.max, a.max);
404    }
405}