1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5use std::time::Duration;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Jitter {
22 None,
24 Equal,
26 Full,
28}
29
30#[derive(Debug, Clone, Copy)]
39pub struct Backoff {
40 pub initial: Duration,
42 pub multiplier: f64,
45 pub max: Duration,
48 pub jitter: Jitter,
50}
51
52impl Default for Backoff {
53 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 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 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 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 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 pub fn base_delay(&self, attempt: u32) -> Duration {
117 if attempt > 64 && self.multiplier >= 1.0 {
122 return self.max;
123 }
124 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 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 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 pub fn should_give_up(attempt: u32, max_attempts: u32) -> bool {
163 attempt >= max_attempts
164 }
165}
166
167#[inline]
170fn scale_random(seed: u64, ceiling: u64) -> u64 {
171 if ceiling == 0 {
172 return 0;
173 }
174 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 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 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 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 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 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 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 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 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}