Skip to main content

origin_sync/
backoff.rs

1use time::Duration;
2
3/// Exponential backoff with a cap and jitter.
4#[derive(Debug, Clone, Copy, PartialEq)]
5pub struct Backoff {
6    /// Delay after the first failure.
7    pub base: Duration,
8    /// Upper bound, however many failures accumulate.
9    pub max: Duration,
10    /// Growth per failure.
11    pub multiplier: u32,
12    /// Fraction of the delay that is randomised, 0.0 to 1.0.
13    pub jitter: f64,
14}
15
16impl Default for Backoff {
17    fn default() -> Self {
18        Self {
19            base: Duration::seconds(30),
20            max: Duration::minutes(30),
21            multiplier: 2,
22            // ±20 %: enough to keep several targets that failed together from
23            // retrying in lockstep and hammering a recovering service.
24            jitter: 0.2,
25        }
26    }
27}
28
29impl Backoff {
30    /// Delay after `failures` consecutive failures.
31    ///
32    /// `random` is a value in `0.0..=1.0` supplied by the caller, which keeps this a
33    /// pure function — jitter is otherwise untestable.
34    pub fn delay_for(&self, failures: u32, random: f64) -> Duration {
35        if failures == 0 {
36            return Duration::ZERO;
37        }
38
39        let exponent = failures.saturating_sub(1);
40        // Saturating: 2^32 seconds overflows long before the cap matters.
41        let factor = (self.multiplier as u64).saturating_pow(exponent.min(32));
42        let raw = self
43            .base
44            .saturating_mul(factor.min(i32::MAX as u64) as i32)
45            .min(self.max);
46
47        if self.jitter <= 0.0 {
48            return raw;
49        }
50
51        let jitter = self.jitter.clamp(0.0, 1.0);
52        let random = random.clamp(0.0, 1.0);
53        // Spread symmetrically around the raw delay: [1-j, 1+j].
54        let scale = 1.0 - jitter + 2.0 * jitter * random;
55
56        let seconds = (raw.as_seconds_f64() * scale).max(0.0);
57        Duration::seconds_f64(seconds).min(self.max)
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    /// No jitter, so the growth curve itself is visible.
66    const PLAIN: Backoff = Backoff {
67        base: Duration::seconds(30),
68        max: Duration::minutes(30),
69        multiplier: 2,
70        jitter: 0.0,
71    };
72
73    #[test]
74    fn a_healthy_target_waits_not_at_all() {
75        assert_eq!(PLAIN.delay_for(0, 0.5), Duration::ZERO);
76    }
77
78    #[test]
79    fn the_delay_doubles_per_failure() {
80        assert_eq!(PLAIN.delay_for(1, 0.5), Duration::seconds(30));
81        assert_eq!(PLAIN.delay_for(2, 0.5), Duration::minutes(1));
82        assert_eq!(PLAIN.delay_for(3, 0.5), Duration::minutes(2));
83        assert_eq!(PLAIN.delay_for(4, 0.5), Duration::minutes(4));
84    }
85
86    #[test]
87    fn the_cap_holds_however_long_the_outage_lasts() {
88        assert_eq!(PLAIN.delay_for(50, 0.5), Duration::minutes(30));
89        assert_eq!(PLAIN.delay_for(u32::MAX, 0.5), Duration::minutes(30));
90    }
91
92    #[test]
93    fn jitter_spreads_symmetrically_around_the_delay() {
94        let backoff = Backoff {
95            jitter: 0.2,
96            ..PLAIN
97        };
98
99        assert_eq!(backoff.delay_for(1, 0.5), Duration::seconds(30));
100        assert_eq!(backoff.delay_for(1, 0.0), Duration::seconds(24)); // −20 %
101        assert_eq!(backoff.delay_for(1, 1.0), Duration::seconds(36)); // +20 %
102    }
103
104    #[test]
105    fn jitter_never_pushes_a_delay_past_the_cap() {
106        let backoff = Backoff {
107            jitter: 0.5,
108            ..PLAIN
109        };
110        assert!(backoff.delay_for(20, 1.0) <= Duration::minutes(30));
111    }
112
113    #[test]
114    fn a_random_value_out_of_range_is_clamped_rather_than_trusted() {
115        let backoff = Backoff {
116            jitter: 0.2,
117            ..PLAIN
118        };
119        assert_eq!(backoff.delay_for(1, 5.0), backoff.delay_for(1, 1.0));
120        assert_eq!(backoff.delay_for(1, -5.0), backoff.delay_for(1, 0.0));
121    }
122}