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}