1use std::{
4 hash::{BuildHasher, RandomState},
5 sync::{
6 OnceLock,
7 atomic::{AtomicU64, Ordering},
8 },
9 time::Duration,
10};
11
12use krabka_units::{Time, convert::TimeExt as _};
13
14#[derive(Clone, Copy, Debug, PartialEq)]
21pub struct ExponentialBackoff {
22 initial_ms: f64,
23 multiplier: f64,
24 max_ms: f64,
25 jitter: f64,
26 exp_max: f64,
27}
28
29pub const KAFKA_BACKOFF_JITTER: f64 = 0.2;
31
32pub const KAFKA_BACKOFF_MULTIPLIER: u32 = 2;
34
35impl ExponentialBackoff {
36 #[must_use]
40 pub fn new(initial: Time, multiplier: u32, max: Time, jitter: f64) -> Self {
41 let jitter = jitter.clamp(0.0, 1.0);
42 let max_ms = max.secs_f64() * 1000.0;
43 let initial_ms = (initial.secs_f64() * 1000.0).min(max_ms);
44 let multiplier = f64::from(multiplier);
45 let exp_max = if max_ms > initial_ms {
46 (max_ms / initial_ms.max(1.0)).log(multiplier)
47 } else {
48 0.0
49 };
50 Self {
51 initial_ms,
52 multiplier,
53 max_ms,
54 jitter,
55 exp_max,
56 }
57 }
58
59 #[must_use]
61 pub fn kafka(initial: Time, max: Time) -> Self {
62 Self::new(initial, KAFKA_BACKOFF_MULTIPLIER, max, KAFKA_BACKOFF_JITTER)
63 }
64
65 #[must_use]
67 pub fn backoff(&self, attempts: u32) -> Duration {
68 self.backoff_with(attempts, random_unit())
69 }
70
71 #[must_use]
74 pub fn backoff_with(&self, attempts: u32, unit: f64) -> Duration {
75 if self.exp_max == 0.0 {
76 return millis_duration(self.initial_ms);
77 }
78 let exp = f64::from(attempts).min(self.exp_max);
79 let term = self.initial_ms * self.multiplier.powf(exp);
80 let factor = if self.jitter < f64::MIN_POSITIVE {
81 1.0
82 } else {
83 (unit * 2.0).mul_add(self.jitter, 1.0 - self.jitter)
84 };
85 millis_duration((factor * term).trunc().min(self.max_ms))
87 }
88}
89
90fn millis_duration(milliseconds: f64) -> Duration {
91 Duration::from_secs_f64(milliseconds.max(0.0) / 1000.0)
92}
93
94pub fn random_below(bound: usize) -> usize {
96 let bound = u128::try_from(bound).unwrap_or(u128::MAX);
97 usize::try_from((u128::from(random_bits()) * bound) >> 32).unwrap_or(0)
98}
99
100pub fn random_unit() -> f64 {
102 f64::from(random_bits()) / 4_294_967_296.0
103}
104
105fn random_bits() -> u32 {
108 static COUNTER: AtomicU64 = AtomicU64::new(0);
109 static KEY: OnceLock<RandomState> = OnceLock::new();
110 let hash = KEY
111 .get_or_init(RandomState::new)
112 .hash_one(COUNTER.fetch_add(1, Ordering::Relaxed));
113 u32::try_from(hash >> 32).unwrap_or(u32::MAX)
114}
115#[cfg(test)]
116mod tests {
117 use assert2::check;
118 use krabka_units::{millis, secs};
119
120 use super::*;
121
122 #[test]
125 fn backoff_grows_and_caps_as_kafka_does() {
126 let reconnect = ExponentialBackoff::kafka(millis(50), secs(1));
127 let setup = ExponentialBackoff::kafka(secs(10), secs(30));
128 let flat = ExponentialBackoff::kafka(millis(100), millis(100));
129 let above_max = ExponentialBackoff::kafka(secs(2), secs(1));
130 let ms = Duration::from_millis;
131 for (name, backoff, attempts, unit, expected) in [
132 ("reconnect, first", reconnect, 0, 0.5, ms(50)),
133 ("reconnect, second", reconnect, 1, 0.5, ms(100)),
134 ("reconnect, fourth", reconnect, 3, 0.5, ms(400)),
135 ("reconnect, low jitter", reconnect, 3, 0.0, ms(320)),
136 ("reconnect, capped", reconnect, 10, 0.5, ms(1000)),
137 (
138 "reconnect, jitter below the cap",
139 reconnect,
140 4,
141 0.99,
142 ms(956),
143 ),
144 (
145 "reconnect, capped after jitter",
146 reconnect,
147 5,
148 0.99,
149 ms(1000),
150 ),
151 ("setup timeout, first", setup, 0, 0.5, ms(10_000)),
152 ("setup timeout, second", setup, 1, 0.5, ms(20_000)),
153 ("setup timeout, capped", setup, 2, 0.5, ms(30_000)),
154 ("no growth when max equals initial", flat, 5, 0.9, ms(100)),
155 ("initial above max becomes max", above_max, 0, 0.9, ms(1000)),
156 ] {
157 check!(backoff.backoff_with(attempts, unit) == expected, "{name}");
158 }
159 }
160
161 #[test]
162 fn random_backoff_stays_within_the_jitter_bounds() {
163 let backoff = ExponentialBackoff::kafka(millis(50), secs(1));
164 for _ in 0..1000 {
165 let value = backoff.backoff(2);
166 check!(value >= Duration::from_millis(160));
167 check!(value < Duration::from_millis(240));
168 }
169 }
170}