Skip to main content

polyoxide_ws/
backoff.rs

1//! The reconnect delay schedule (W3).
2
3use std::time::Duration;
4
5/// The shortest delay. Zero would never grow, so a dead host would be retried
6/// on every timer tick.
7pub const MIN_BACKOFF: Duration = Duration::from_millis(1);
8
9/// The reconnect delay schedule: doubling from an initial delay to a ceiling,
10/// and back to the start after a connection that delivered something.
11///
12/// A plain schedule, with no clock and no socket, so the doubling and the
13/// ceiling can be checked as arithmetic. As statements interleaved with a
14/// `sleep` and a live socket they could only be checked by timing a real
15/// reconnect, so neither was, and a schedule pinned at its initial value would
16/// have hammered an unwell host without failing a test.
17///
18/// The basis is one socket crate's former copy (`src/supervisor.rs`),
19/// identical to the copies three other socket crates carried. Each copy was
20/// deleted for this one in Stories 4.6 to 4.8, and each crate that carried
21/// one takes its schedule from here.
22/// Two divergence rows stay with each venue:
23///
24/// - **D6.** What counts as a connection that delivered something follows the
25///   venue's liveness rule (D5): any frame, a decoded event, or a ping. The
26///   call site decides and passes it to
27///   [`after_connection_ended`](Self::after_connection_ended) as a `bool`.
28/// - **D13.** `polyoxide-core`'s `RetryConfig::backoff` is a different
29///   schedule, jittered, for HTTP retries. The two are never unified.
30///
31/// ```
32/// use std::time::Duration;
33/// use polyoxide_ws::Backoff;
34///
35/// let mut backoff = Backoff::new(Duration::from_millis(10), Duration::from_millis(30));
36/// assert_eq!(backoff.take(), Duration::from_millis(10));
37/// assert_eq!(backoff.take(), Duration::from_millis(20));
38/// assert_eq!(backoff.take(), Duration::from_millis(30));
39/// backoff.after_connection_ended(true);
40/// assert_eq!(backoff.take(), Duration::from_millis(10));
41/// ```
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct Backoff {
44    initial: Duration,
45    max: Duration,
46    next: Duration,
47}
48
49impl Backoff {
50    /// A schedule starting at `initial` and doubling towards `max`.
51    ///
52    /// Both are raised to [`MIN_BACKOFF`] first, and `initial` is then lowered
53    /// to `max`: a builder's `backoff(initial, max)` takes the two
54    /// independently, and nothing stops a caller passing them the wrong way
55    /// round, which would otherwise wait the larger delay once before the
56    /// ceiling took effect.
57    pub fn new(initial: Duration, max: Duration) -> Self {
58        let max = max.max(MIN_BACKOFF);
59        let initial = initial.clamp(MIN_BACKOFF, max);
60        Self {
61            initial,
62            max,
63            next: initial,
64        }
65    }
66
67    /// The delay to wait before the next attempt, advancing the schedule. The
68    /// one after it doubles, up to the ceiling, saturating rather than
69    /// overflowing.
70    pub fn take(&mut self) -> Duration {
71        let delay = self.next;
72        self.next = self.next.saturating_mul(2).min(self.max);
73        delay
74    }
75
76    /// As [`take`](Self::take), but at least `floor`, clamped to the
77    /// ceiling: the wait a refused attempt asked for, such as its
78    /// `Retry-After`, may only extend the delay, never shorten it, and never
79    /// past the ceiling (DRIFT R11). The schedule advances as `take`'s does.
80    ///
81    /// The rule is `polyoxide-core`'s for HTTP, where `Retry-After` only
82    /// extends the retry delay; the two schedules stay apart (D13).
83    pub fn take_at_least(&mut self, floor: Duration) -> Duration {
84        self.take().max(floor.min(self.max))
85    }
86
87    /// Note that a connection ended, and whether it delivered anything.
88    ///
89    /// Resetting on `delivered` rather than on a successful connect is what
90    /// stops a host that accepts and immediately closes from pinning the delay
91    /// at its initial value. A host that reliably delivers exactly one frame
92    /// before dying still resets the schedule each time; most unhealthy hosts
93    /// fail before delivering anything.
94    pub fn after_connection_ended(&mut self, delivered: bool) {
95        if delivered {
96            self.next = self.initial;
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    /// The schedule as arithmetic, with no clock and no socket involved.
106    mod backoff_schedule {
107        use super::*;
108
109        fn millis(ms: u64) -> Duration {
110            Duration::from_millis(ms)
111        }
112
113        fn take_n(backoff: &mut Backoff, n: usize) -> Vec<Duration> {
114            (0..n).map(|_| backoff.take()).collect()
115        }
116
117        #[test]
118        fn the_delay_doubles_up_to_the_ceiling_and_then_holds() {
119            let mut backoff = Backoff::new(millis(10), millis(80));
120            assert_eq!(
121                take_n(&mut backoff, 6),
122                vec![
123                    millis(10),
124                    millis(20),
125                    millis(40),
126                    millis(80),
127                    millis(80),
128                    millis(80)
129                ],
130                "a schedule that stops escalating hammers a host that is \
131                 already unwell"
132            );
133        }
134
135        #[test]
136        fn the_ceiling_is_a_clamp_rather_than_a_step() {
137            // 30 doubles to 60, which is past 50 without ever equalling it. A
138            // ceiling implemented as an equality check would run away here.
139            let mut backoff = Backoff::new(millis(30), millis(50));
140            assert_eq!(
141                take_n(&mut backoff, 3),
142                vec![millis(30), millis(50), millis(50)]
143            );
144        }
145
146        #[test]
147        fn a_reset_returns_to_the_initial_delay_not_to_zero() {
148            let mut backoff = Backoff::new(millis(10), millis(80));
149            take_n(&mut backoff, 3);
150            backoff.after_connection_ended(true);
151            assert_eq!(
152                take_n(&mut backoff, 2),
153                vec![millis(10), millis(20)],
154                "a reset restarts the schedule; it does not remove the wait"
155            );
156        }
157
158        #[test]
159        fn only_a_connection_that_delivered_something_resets_the_schedule() {
160            // The distinction the `delivered` flag exists for. A host that
161            // accepts and immediately closes must keep escalating.
162            let mut escalating = Backoff::new(millis(10), millis(80));
163            let mut recovering = Backoff::new(millis(10), millis(80));
164            let (mut escalated, mut recovered) = (Vec::new(), Vec::new());
165
166            for _ in 0..4 {
167                escalating.after_connection_ended(false);
168                escalated.push(escalating.take());
169                recovering.after_connection_ended(true);
170                recovered.push(recovering.take());
171            }
172
173            assert_eq!(
174                escalated,
175                vec![millis(10), millis(20), millis(40), millis(80)]
176            );
177            assert_eq!(
178                recovered,
179                vec![millis(10), millis(10), millis(10), millis(10)]
180            );
181        }
182
183        #[test]
184        fn a_ceiling_below_the_initial_delay_is_still_honoured() {
185            // `backoff(initial, max)` takes the two independently, so nothing
186            // stops a caller swapping them. Without the clamp the first wait
187            // would be a minute regardless of the ceiling.
188            let mut backoff = Backoff::new(Duration::from_secs(60), millis(1));
189            assert_eq!(take_n(&mut backoff, 2), vec![millis(1), millis(1)]);
190
191            backoff.after_connection_ended(true);
192            assert_eq!(backoff.take(), millis(1), "a reset must respect it too");
193        }
194
195        #[test]
196        fn a_zero_initial_delay_still_grows() {
197            // Zero doubles to zero, which would retry a dead host on every
198            // timer tick for ever.
199            let mut backoff = Backoff::new(Duration::ZERO, millis(80));
200            assert_eq!(
201                take_n(&mut backoff, 3),
202                vec![millis(1), millis(2), millis(4)]
203            );
204        }
205
206        #[test]
207        fn a_floor_extends_the_delay_and_never_shortens_it_drift_r11() {
208            // A step of 1 s, a ceiling of 60 s: a 30 s wait is waited in
209            // full, and a floor under the step leaves the step alone.
210            let mut backoff = Backoff::new(Duration::from_secs(1), Duration::from_secs(60));
211            assert_eq!(
212                backoff.take_at_least(Duration::from_secs(30)),
213                Duration::from_secs(30)
214            );
215            assert_eq!(backoff.take_at_least(millis(500)), Duration::from_secs(2));
216            assert_eq!(
217                backoff.take_at_least(Duration::ZERO),
218                Duration::from_secs(4)
219            );
220        }
221
222        #[test]
223        fn a_floor_past_the_ceiling_waits_the_ceiling_drift_r11() {
224            let mut backoff = Backoff::new(Duration::from_secs(1), Duration::from_secs(60));
225            assert_eq!(
226                backoff.take_at_least(Duration::from_secs(600)),
227                Duration::from_secs(60)
228            );
229            assert_eq!(
230                backoff.take_at_least(Duration::MAX),
231                Duration::from_secs(60)
232            );
233        }
234
235        #[test]
236        fn a_floor_does_not_move_the_schedule() {
237            let mut floored = Backoff::new(millis(10), millis(80));
238            let mut plain = floored;
239            floored.take_at_least(millis(70));
240            plain.take();
241            assert_eq!(take_n(&mut floored, 3), take_n(&mut plain, 3));
242        }
243
244        #[test]
245        fn a_huge_delay_saturates_rather_than_panicking() {
246            let mut backoff = Backoff::new(Duration::MAX, Duration::MAX);
247            assert_eq!(take_n(&mut backoff, 2), vec![Duration::MAX, Duration::MAX]);
248        }
249    }
250}