Skip to main content

sozu_lib/
retry.rs

1//! Backoff after a failed backend connection.
2//!
3//! Nothing here reads a clock or draws from an ambient RNG: every method that
4//! depends on time takes `now`, and [`RetryPolicy::fail`] draws its jitter from
5//! the RNG it is lent. The worker passes the pass's clock sample and the
6//! backend map's RNG; a simulator passes its own, so the backoff windows a
7//! seed produces are a pure function of that seed and of the instants it
8//! supplies (#1684).
9
10use std::{
11    cmp,
12    fmt::Debug,
13    time::{Duration, Instant},
14};
15
16use rand::{Rng, RngExt};
17
18#[derive(Debug, PartialEq, Eq)]
19pub enum RetryAction {
20    OKAY,
21    WAIT,
22}
23
24pub trait RetryPolicy: Debug + PartialEq + Eq {
25    fn max_tries(&self) -> usize;
26    fn current_tries(&self) -> usize;
27
28    /// Record a failed attempt at `now`, drawing the backoff jitter from `rng`.
29    fn fail<R: Rng + ?Sized>(&mut self, now: Instant, rng: &mut R);
30    /// Record a successful attempt at `now`.
31    fn succeed(&mut self, now: Instant);
32
33    /// Whether an attempt may be made at `now`.
34    fn can_try(&self, _now: Instant) -> Option<RetryAction> {
35        if self.current_tries() >= self.max_tries() {
36            None
37        } else {
38            Some(RetryAction::OKAY)
39        }
40    }
41
42    fn is_down(&self) -> bool;
43}
44
45#[derive(Debug, PartialEq, Eq, Clone)]
46pub enum RetryPolicyWrapper {
47    ExponentialBackoff(ExponentialBackoffPolicy),
48}
49
50#[derive(Debug, PartialEq, Eq, Clone)]
51pub struct ExponentialBackoffPolicy {
52    max_tries: usize,
53    current_tries: usize,
54    /// When the last attempt was recorded; `None` until the first one.
55    ///
56    /// `wait` is zero whenever this is `None`, so no instant is needed at
57    /// construction: a policy that has never failed can always be tried.
58    last_try: Option<Instant>,
59    wait: Duration,
60}
61
62impl ExponentialBackoffPolicy {
63    pub fn new(max_tries: usize) -> Self {
64        ExponentialBackoffPolicy {
65            max_tries,
66            current_tries: 0,
67            last_try: None,
68            wait: Duration::ZERO,
69        }
70    }
71
72    /// Time since the last recorded attempt, as seen at `now`.
73    ///
74    /// Saturates at zero, so an injected `now` earlier than the last attempt
75    /// reads as "no time has passed" rather than panicking. With no attempt
76    /// recorded, `wait` is zero and any value compares the same way.
77    fn since_last_try(&self, now: Instant) -> Duration {
78        self.last_try.map_or(Duration::ZERO, |last_try| {
79            now.saturating_duration_since(last_try)
80        })
81    }
82}
83
84impl RetryPolicy for ExponentialBackoffPolicy {
85    fn max_tries(&self) -> usize {
86        self.max_tries
87    }
88
89    fn current_tries(&self) -> usize {
90        self.current_tries
91    }
92
93    fn fail<R: Rng + ?Sized>(&mut self, now: Instant, rng: &mut R) {
94        if self.since_last_try(now).lt(&self.wait) {
95            //we're already in back off
96            return;
97        }
98
99        let max_secs = cmp::max(
100            1,
101            1u64.checked_shl(self.current_tries as u32)
102                .unwrap_or(u64::MAX),
103        );
104        let wait = if max_secs == 1 {
105            1
106        } else {
107            rng.random_range(1..max_secs)
108        };
109
110        self.wait = Duration::from_secs(wait);
111        self.last_try = Some(now);
112        self.current_tries = cmp::min(self.current_tries + 1, self.max_tries);
113    }
114
115    fn succeed(&mut self, now: Instant) {
116        self.wait = Duration::default();
117        self.last_try = Some(now);
118        self.current_tries = 0;
119    }
120
121    fn can_try(&self, now: Instant) -> Option<RetryAction> {
122        let action = if self.since_last_try(now).ge(&self.wait) {
123            RetryAction::OKAY
124        } else {
125            RetryAction::WAIT
126        };
127
128        Some(action)
129    }
130
131    fn is_down(&self) -> bool {
132        self.current_tries() >= self.max_tries()
133    }
134}
135
136#[cfg(test)]
137impl ExponentialBackoffPolicy {
138    /// Test-only helper that drives the policy directly to the
139    /// "exhausted-budget" state. The natural path requires `max_tries`
140    /// successive `fail()` calls separated by the exponential-backoff
141    /// wait window (up to 32+ s with the default `max_tries = 6`), which
142    /// is not practical inside a unit test. This helper forces
143    /// `current_tries == max_tries`, so `is_down()` returns true and
144    /// `Backend::is_available()` flips off without sleeping.
145    pub(crate) fn force_down(&mut self) {
146        self.current_tries = self.max_tries;
147    }
148}
149
150#[cfg(test)]
151impl RetryPolicyWrapper {
152    /// Test-only forwarder for [`ExponentialBackoffPolicy::force_down`].
153    pub(crate) fn force_down(&mut self) {
154        match self {
155            RetryPolicyWrapper::ExponentialBackoff(p) => p.force_down(),
156        }
157    }
158}
159
160impl From<ExponentialBackoffPolicy> for RetryPolicyWrapper {
161    fn from(val: ExponentialBackoffPolicy) -> Self {
162        RetryPolicyWrapper::ExponentialBackoff(val)
163    }
164}
165
166impl RetryPolicy for RetryPolicyWrapper {
167    fn max_tries(&self) -> usize {
168        match *self {
169            RetryPolicyWrapper::ExponentialBackoff(ref policy) => policy,
170        }
171        .max_tries()
172    }
173
174    fn current_tries(&self) -> usize {
175        match *self {
176            RetryPolicyWrapper::ExponentialBackoff(ref policy) => policy,
177        }
178        .current_tries()
179    }
180
181    fn fail<R: Rng + ?Sized>(&mut self, now: Instant, rng: &mut R) {
182        match *self {
183            RetryPolicyWrapper::ExponentialBackoff(ref mut policy) => policy,
184        }
185        .fail(now, rng)
186    }
187
188    fn succeed(&mut self, now: Instant) {
189        match *self {
190            RetryPolicyWrapper::ExponentialBackoff(ref mut policy) => policy,
191        }
192        .succeed(now)
193    }
194
195    fn can_try(&self, now: Instant) -> Option<RetryAction> {
196        match *self {
197            RetryPolicyWrapper::ExponentialBackoff(ref policy) => policy,
198        }
199        .can_try(now)
200    }
201
202    fn is_down(&self) -> bool {
203        match *self {
204            RetryPolicyWrapper::ExponentialBackoff(ref policy) => policy,
205        }
206        .is_down()
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use std::time::{Duration, Instant};
213
214    use rand::{SeedableRng, rngs::StdRng};
215
216    use super::{ExponentialBackoffPolicy, RetryAction, RetryPolicy};
217
218    const MAX_FAILS: usize = 10;
219
220    fn rng() -> StdRng {
221        StdRng::seed_from_u64(0x5eed)
222    }
223
224    #[test]
225    fn no_fail() {
226        let policy = ExponentialBackoffPolicy::new(MAX_FAILS);
227        let can_try = policy.can_try(Instant::now());
228
229        assert_eq!(Some(RetryAction::OKAY), can_try)
230    }
231
232    #[test]
233    fn single_fail() {
234        let t0 = Instant::now();
235        let mut policy = ExponentialBackoffPolicy::new(MAX_FAILS);
236        policy.fail(t0, &mut rng());
237        let can_try = policy.can_try(t0);
238
239        // The wait is >= 1s, and no time has passed on the injected clock.
240        assert_eq!(Some(RetryAction::WAIT), can_try)
241    }
242
243    #[test]
244    fn max_fails() {
245        let t0 = Instant::now();
246        let mut rng = rng();
247        let mut policy = ExponentialBackoffPolicy::new(MAX_FAILS);
248
249        for _ in 0..MAX_FAILS {
250            policy.fail(t0, &mut rng);
251        }
252
253        let can_try = policy.can_try(t0);
254
255        assert_eq!(Some(RetryAction::WAIT), can_try)
256    }
257
258    #[test]
259    fn recover_from_fail() {
260        let t0 = Instant::now();
261        let mut rng = rng();
262        let mut policy = ExponentialBackoffPolicy::new(MAX_FAILS);
263
264        // Stop just before total failure
265        for _ in 0..(MAX_FAILS - 1) {
266            policy.fail(t0, &mut rng);
267        }
268
269        policy.succeed(t0);
270        policy.fail(t0, &mut rng);
271        policy.fail(t0, &mut rng);
272        policy.fail(t0, &mut rng);
273
274        let can_try = policy.can_try(t0);
275
276        assert_eq!(Some(RetryAction::WAIT), can_try)
277    }
278
279    /// #1684: the backoff window is measured on the clock the caller passes,
280    /// not on the wall clock.
281    ///
282    /// The first failure always waits exactly one second (`max_secs == 1`,
283    /// no jitter), so the window closes at `t0 + 1s` on the injected clock
284    /// while almost no real time has passed.
285    ///
286    /// TO SEE THIS RED: measure `since_last_try` from `Instant::now()`
287    /// instead of `now`; `can_try(t0 + 1s)` then still answers `WAIT`.
288    #[test]
289    fn the_backoff_window_follows_the_injected_clock() {
290        let t0 = Instant::now();
291        let mut policy = ExponentialBackoffPolicy::new(MAX_FAILS);
292        policy.fail(t0, &mut rng());
293
294        let one_second = Duration::from_secs(1);
295        assert_eq!(
296            policy.can_try(t0 + one_second - Duration::from_nanos(1)),
297            Some(RetryAction::WAIT),
298            "one nanosecond before the window closes, the policy must wait"
299        );
300        assert_eq!(
301            policy.can_try(t0 + one_second),
302            Some(RetryAction::OKAY),
303            "the window closes at t0 + 1s on the injected clock"
304        );
305    }
306
307    /// Every backoff window a policy chooses, failing again the instant the
308    /// previous window closes, until the budget is spent.
309    fn windows(seed: u64) -> Vec<Duration> {
310        let mut rng = StdRng::seed_from_u64(seed);
311        let mut policy = ExponentialBackoffPolicy::new(MAX_FAILS);
312        let mut now = Instant::now();
313        let mut windows = Vec::with_capacity(MAX_FAILS);
314        while !policy.is_down() {
315            policy.fail(now, &mut rng);
316            windows.push(policy.wait);
317            now += policy.wait;
318        }
319        windows
320    }
321
322    /// #1684: the jitter of every backoff window is drawn from the RNG the
323    /// caller lends, so one seed always yields one sequence of windows.
324    ///
325    /// TO SEE THIS RED: draw the jitter in `fail` from `rand::rng()` and
326    /// ignore the argument; two runs of one seed then disagree.
327    #[test]
328    fn one_seed_yields_one_sequence_of_backoff_windows() {
329        let first = windows(7);
330        assert_eq!(first.len(), MAX_FAILS);
331        assert_eq!(first, windows(7), "one seed must yield one sequence");
332        assert_ne!(
333            first,
334            windows(8),
335            "the jitter must come from the lent RNG, so another seed differs"
336        );
337    }
338}