Skip to main content

wowlab_centrifuge/
backoff.rs

1//! AWS-style full jitter backoff.
2
3use std::time::Duration;
4
5use rand::RngExt;
6use wowlab_common::retry::ExponentialSchedule;
7
8const DEFAULT_MIN_DELAY: Duration = Duration::from_millis(500);
9const DEFAULT_MAX_DELAY: Duration = Duration::from_secs(20);
10const BACKOFF_MULTIPLIER: u32 = 2;
11
12/// Exponential reconnect schedule with full jitter and bounded delays.
13#[derive(Clone, Debug)]
14pub struct Backoff {
15    min_delay: Duration,
16    max_delay: Duration,
17    schedule: ExponentialSchedule,
18}
19
20impl Backoff {
21    /// Creates a backoff schedule bounded by `min_delay` and `max_delay`.
22    #[must_use]
23    pub fn new(min_delay: Duration, max_delay: Duration) -> Self {
24        Self {
25            min_delay,
26            max_delay,
27            schedule: ExponentialSchedule::new(min_delay, max_delay, BACKOFF_MULTIPLIER),
28        }
29    }
30
31    /// Advances the schedule and returns the next jittered delay.
32    pub fn next_delay(&mut self) -> Duration {
33        jittered_delay(self.schedule.next_delay(), self.min_delay, self.max_delay)
34    }
35
36    /// Returns a jittered delay for the current schedule position without advancing it.
37    #[must_use]
38    pub fn calculate_delay(&self) -> Duration {
39        jittered_delay(
40            self.schedule.current_delay(),
41            self.min_delay,
42            self.max_delay,
43        )
44    }
45
46    /// Restarts the schedule at its minimum delay.
47    pub fn reset(&mut self) {
48        self.schedule.reset();
49    }
50}
51
52impl Default for Backoff {
53    fn default() -> Self {
54        Self::new(DEFAULT_MIN_DELAY, DEFAULT_MAX_DELAY)
55    }
56}
57
58fn jittered_delay(scheduled_delay: Duration, min_delay: Duration, max_delay: Duration) -> Duration {
59    let duration_millis =
60        |duration: Duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
61    let base_ms = duration_millis(min_delay);
62    let max_ms = duration_millis(max_delay);
63    let scheduled_ms = duration_millis(scheduled_delay);
64
65    let interval = if scheduled_ms > 0 {
66        rand::rng().random_range(0..=scheduled_ms)
67    } else {
68        0
69    };
70
71    let result = base_ms.saturating_add(interval).min(max_ms);
72
73    Duration::from_millis(result)
74}
75
76#[cfg(test)]
77mod tests {
78    use googletest::prelude::*;
79
80    use super::*;
81
82    #[gtest]
83    fn test_backoff_increases() -> Result<()> {
84        let mut backoff = Backoff::new(Duration::from_millis(100), Duration::from_secs(10));
85
86        let mut max_seen = Duration::ZERO;
87
88        for _ in 0..1000 {
89            backoff.reset();
90
91            for _ in 0..10 {
92                let delay = backoff.next_delay();
93
94                if delay > max_seen {
95                    max_seen = delay;
96                }
97            }
98        }
99
100        verify_that!(max_seen, ge(Duration::from_secs(5)))
101    }
102
103    #[gtest]
104    fn test_backoff_respects_max() -> Result<()> {
105        let mut backoff = Backoff::new(Duration::from_millis(100), Duration::from_secs(10));
106
107        for _ in 0..50 {
108            let delay = backoff.next_delay();
109
110            verify_that!(delay, le(Duration::from_secs(10)))?;
111            verify_that!(delay, ge(Duration::from_millis(100)))?;
112        }
113
114        Ok(())
115    }
116
117    #[gtest]
118    fn test_backoff_reset() -> Result<()> {
119        let mut backoff = Backoff::default();
120
121        backoff.next_delay();
122        backoff.next_delay();
123        verify_that!(backoff.calculate_delay(), ge(DEFAULT_MIN_DELAY))?;
124
125        backoff.reset();
126
127        verify_that!(
128            backoff.calculate_delay(),
129            le(DEFAULT_MIN_DELAY.saturating_mul(BACKOFF_MULTIPLIER))
130        )
131    }
132}