1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
//! Exponential backoff policy for transient failures.
//!
//! Transient failures (5xx, network blips, rate limits) are worth retrying with
//! an exponentially growing, capped delay and jitter to avoid thundering herds.
//! [`Backoff`] is the *policy* — it computes delays and the retry decision; the
//! actual sleep-and-retry loop lives in the real transport adapter and uses this
//! policy. Jitter takes a `[0, 1)` fraction as an argument so the policy stays
//! pure and testable (no hidden RNG).
/// An exponential-backoff policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Backoff {
base_ms: u64,
max_ms: u64,
max_retries: u32,
}
impl Backoff {
/// A policy starting at `base_ms`, doubling each attempt, capped at `max_ms`,
/// giving up after `max_retries`.
///
/// `const` so a caller can state its policy as a constant next to the
/// reasoning for it, rather than rebuilding one per call.
#[must_use]
pub const fn new(base_ms: u64, max_ms: u64, max_retries: u32) -> Self {
Self {
base_ms,
max_ms,
max_retries,
}
}
/// The (un-jittered) delay before retry `attempt` (0-based): `base * 2^attempt`,
/// saturating and capped at `max_ms`.
#[must_use]
pub fn delay_ms(&self, attempt: u32) -> u64 {
let factor = 2u64.saturating_pow(attempt);
self.base_ms.saturating_mul(factor).min(self.max_ms)
}
/// The full-jitter delay for `attempt`: a uniform sample in `[0, delay]`,
/// given a random fraction `rand01` in `[0, 1)`.
#[must_use]
pub fn jittered_delay_ms(&self, attempt: u32, rand01: f64) -> u64 {
let clamped = rand01.clamp(0.0, 1.0);
#[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
let jittered = self.delay_ms(attempt) as f64 * clamped;
jittered as u64
}
/// Whether an `attempt` (0-based) should be retried.
#[must_use]
pub fn should_retry(&self, attempt: u32) -> bool {
attempt < self.max_retries
}
/// The configured maximum number of retries.
#[must_use]
pub fn max_retries(&self) -> u32 {
self.max_retries
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn delay_doubles_and_caps() {
let b = Backoff::new(100, 2_000, 5);
assert_eq!(b.delay_ms(0), 100);
assert_eq!(b.delay_ms(1), 200);
assert_eq!(b.delay_ms(2), 400);
assert_eq!(b.delay_ms(3), 800);
assert_eq!(b.delay_ms(4), 1_600);
// Capped.
assert_eq!(b.delay_ms(5), 2_000);
assert_eq!(b.delay_ms(60), 2_000);
}
#[test]
fn jitter_scales_within_the_delay() {
let b = Backoff::new(100, 10_000, 5);
assert_eq!(b.jittered_delay_ms(2, 0.0), 0);
assert_eq!(b.jittered_delay_ms(2, 0.5), 200); // 400 * 0.5
assert_eq!(b.jittered_delay_ms(2, 1.0), 400);
// Out-of-range fractions are clamped.
assert_eq!(b.jittered_delay_ms(2, -1.0), 0);
assert_eq!(b.jittered_delay_ms(2, 2.0), 400);
}
#[test]
fn retry_decision_respects_the_limit() {
let b = Backoff::new(100, 1_000, 3);
assert_eq!(b.max_retries(), 3);
assert!(b.should_retry(0));
assert!(b.should_retry(2));
assert!(!b.should_retry(3));
assert!(!b.should_retry(10));
}
}