Skip to main content

nautilus_network/
backoff.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Provides an implementation of an exponential backoff mechanism with jitter support.
17//! It is used for managing reconnection delays in the socket clients.
18//!
19//! The backoff mechanism allows the delay to grow exponentially up to a configurable
20//! maximum, optionally applying random jitter to avoid synchronized reconnection storms.
21//! An "immediate first" flag is available so that the very first reconnect attempt
22//! can occur without any delay.
23
24use std::time::Duration;
25
26use nautilus_core::correctness::{check_in_range_inclusive_f64, check_predicate_true};
27use rand::RngExt;
28
29#[derive(Clone, Debug)]
30pub struct ExponentialBackoff {
31    /// The initial backoff delay.
32    delay_initial: Duration,
33    /// The maximum delay to cap the backoff.
34    delay_max: Duration,
35    /// The current backoff delay.
36    delay_current: Duration,
37    /// The factor to multiply the delay on each iteration.
38    factor: f64,
39    /// The maximum random jitter to add (in milliseconds).
40    jitter_ms: u64,
41    /// If true, the first call to `next()` returns zero delay (immediate reconnect).
42    immediate_reconnect: bool,
43    /// The original value of `immediate_reconnect` for reset purposes.
44    immediate_reconnect_original: bool,
45}
46
47/// An exponential backoff mechanism with optional jitter and immediate-first behavior.
48///
49/// This struct computes successive delays for reconnect attempts.
50/// It starts from an initial delay and multiplies it by a factor on each iteration,
51/// capping the delay at a maximum value. Random jitter is added (up to a configured
52/// maximum) to the delay. When `immediate_first` is true, the first call to `next_duration`
53/// returns zero delay, triggering an immediate reconnect, after which the immediate flag is disabled.
54impl ExponentialBackoff {
55    /// Creates a new [`ExponentialBackoff]` instance.
56    ///
57    /// # Errors
58    ///
59    /// Returns an error if:
60    /// - `delay_initial` is zero.
61    /// - `delay_max` is less than `delay_initial`.
62    /// - `delay_max` exceeds `Duration::from_nanos(u64::MAX)` (≈584 years).
63    /// - `factor` is not in the range [1.0, 100.0] (to prevent reconnect spam).
64    pub fn new(
65        delay_initial: Duration,
66        delay_max: Duration,
67        factor: f64,
68        jitter_ms: u64,
69        immediate_first: bool,
70    ) -> anyhow::Result<Self> {
71        check_predicate_true(!delay_initial.is_zero(), "delay_initial must be non-zero")?;
72        check_predicate_true(
73            delay_max >= delay_initial,
74            "delay_max must be >= delay_initial",
75        )?;
76        check_predicate_true(
77            delay_max.as_nanos() <= u128::from(u64::MAX),
78            "delay_max exceeds maximum representable duration (≈584 years)",
79        )?;
80        check_in_range_inclusive_f64(factor, 1.0, 100.0, "factor")?;
81
82        Ok(Self {
83            delay_initial,
84            delay_max,
85            delay_current: delay_initial,
86            factor,
87            jitter_ms,
88            immediate_reconnect: immediate_first,
89            immediate_reconnect_original: immediate_first,
90        })
91    }
92
93    /// Return the next backoff delay with jitter and update the internal state.
94    ///
95    /// If the `immediate_first` flag is set and this is the first call (i.e. the current
96    /// delay equals the initial delay), it returns `Duration::ZERO` to trigger an immediate
97    /// reconnect and disables the immediate behavior for subsequent calls.
98    ///
99    /// Near the cap the jittered base is lowered to `delay_max - jitter` so
100    /// the spread survives saturation; the result is clamped into
101    /// `[min(delay_initial, delay_max), delay_max]`.
102    pub fn next_duration(&mut self) -> Duration {
103        if self.immediate_reconnect && self.delay_current == self.delay_initial {
104            self.immediate_reconnect = false;
105            return Duration::ZERO;
106        }
107
108        // Generate random jitter
109        let jitter = rand::rng().random_range(0..=self.jitter_ms); // dst-ok: transport-layer reconnect jitter, out of DST scope
110
111        // Cap the jittered base below delay_max so the spread survives saturation at the cap
112        let base = std::cmp::min(
113            self.delay_current,
114            self.delay_max
115                .saturating_sub(Duration::from_millis(self.jitter_ms)),
116        );
117        let delay_with_jitter = base + Duration::from_millis(jitter);
118
119        // The floor keeps a jitter range wider than delay_max from producing a zero delay
120        let floor = std::cmp::min(self.delay_initial, self.delay_max);
121        let clamped_delay = delay_with_jitter.clamp(floor, self.delay_max);
122
123        // Prepare the next delay with overflow protection
124        // Keep all math in u128 to avoid silent truncation
125        let current_nanos = self.delay_current.as_nanos();
126        let max_nanos = self.delay_max.as_nanos();
127
128        // Use checked floating point multiplication to prevent overflow
129        let next_nanos_u128 = if current_nanos > u128::from(u64::MAX) {
130            // Current is already at max representable value, cap to max
131            max_nanos
132        } else {
133            let current_u64 = current_nanos as u64;
134            let next_f64 = current_u64 as f64 * self.factor;
135
136            // Check for overflow in the float result
137            if next_f64 > u64::MAX as f64 {
138                u128::from(u64::MAX)
139            } else {
140                u128::from(next_f64 as u64)
141            }
142        };
143
144        let clamped = std::cmp::min(next_nanos_u128, max_nanos);
145        let final_nanos = if clamped > u128::from(u64::MAX) {
146            u64::MAX
147        } else {
148            clamped as u64
149        };
150
151        self.delay_current = Duration::from_nanos(final_nanos);
152
153        clamped_delay
154    }
155
156    /// Reset the backoff to its initial state.
157    pub const fn reset(&mut self) {
158        self.delay_current = self.delay_initial;
159        self.immediate_reconnect = self.immediate_reconnect_original;
160    }
161
162    /// Returns the current base delay without jitter.
163    /// This represents the delay that would be used as the base for the next call to `next()`,
164    /// before any jitter is applied.
165    #[must_use]
166    pub const fn current_delay(&self) -> Duration {
167        self.delay_current
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use std::time::Duration;
174
175    use rstest::rstest;
176
177    use super::*;
178
179    #[rstest]
180    fn test_no_jitter_exponential_growth() {
181        let initial = Duration::from_millis(100);
182        let max = Duration::from_millis(1600);
183        let factor = 2.0;
184        let jitter = 0;
185        let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false).unwrap();
186
187        // 1st call returns the initial delay
188        let d1 = backoff.next_duration();
189        assert_eq!(d1, Duration::from_millis(100));
190
191        // 2nd call: current becomes 200ms
192        let d2 = backoff.next_duration();
193        assert_eq!(d2, Duration::from_millis(200));
194
195        // 3rd call: current becomes 400ms
196        let d3 = backoff.next_duration();
197        assert_eq!(d3, Duration::from_millis(400));
198
199        // 4th call: current becomes 800ms
200        let d4 = backoff.next_duration();
201        assert_eq!(d4, Duration::from_millis(800));
202
203        // 5th call: current would be 1600ms (800 * 2) which is within the cap
204        let d5 = backoff.next_duration();
205        assert_eq!(d5, Duration::from_millis(1600));
206
207        // 6th call: should still be capped at 1600ms
208        let d6 = backoff.next_duration();
209        assert_eq!(d6, Duration::from_millis(1600));
210    }
211
212    #[rstest]
213    fn test_reset() {
214        let initial = Duration::from_millis(100);
215        let max = Duration::from_millis(1600);
216        let factor = 2.0;
217        let jitter = 0;
218        let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false).unwrap();
219
220        // Call next() once so that the internal state updates
221        let _ = backoff.next_duration(); // current_delay becomes 200ms
222        backoff.reset();
223        let d = backoff.next_duration();
224        // After reset, the next delay should be the initial delay (100ms)
225        assert_eq!(d, Duration::from_millis(100));
226    }
227
228    #[rstest]
229    fn test_jitter_within_bounds() {
230        let initial = Duration::from_millis(100);
231        let max = Duration::from_secs(1);
232        let factor = 2.0;
233        let jitter = 50;
234        // Run several iterations to ensure that jitter stays within bounds
235        for _ in 0..10 {
236            let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false).unwrap();
237            // Capture the expected base delay before jitter is applied
238            let base = backoff.delay_current;
239            let delay = backoff.next_duration();
240            // The returned delay must be at least the base delay and at most base + jitter
241            let min_expected = base;
242            let max_expected = base + Duration::from_millis(jitter);
243            assert!(
244                delay >= min_expected,
245                "Delay {delay:?} is less than expected minimum {min_expected:?}"
246            );
247            assert!(
248                delay <= max_expected,
249                "Delay {delay:?} exceeds expected maximum {max_expected:?}"
250            );
251        }
252    }
253
254    #[rstest]
255    fn test_factor_less_than_two() {
256        let initial = Duration::from_millis(100);
257        let max = Duration::from_millis(200);
258        let factor = 1.5;
259        let jitter = 0;
260        let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false).unwrap();
261
262        // First call returns 100ms
263        let d1 = backoff.next_duration();
264        assert_eq!(d1, Duration::from_millis(100));
265
266        // Second call: current_delay becomes 100 * 1.5 = 150ms
267        let d2 = backoff.next_duration();
268        assert_eq!(d2, Duration::from_millis(150));
269
270        // Third call: current_delay becomes 150 * 1.5 = 225ms, but capped to 200ms
271        let d3 = backoff.next_duration();
272        assert_eq!(d3, Duration::from_millis(200));
273
274        // Fourth call: remains at the max of 200ms
275        let d4 = backoff.next_duration();
276        assert_eq!(d4, Duration::from_millis(200));
277    }
278
279    #[rstest]
280    fn test_max_delay_is_respected() {
281        let initial = Duration::from_millis(500);
282        let max = Duration::from_secs(1);
283        let factor = 3.0;
284        let jitter = 0;
285        let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false).unwrap();
286
287        // 1st call returns 500ms
288        let d1 = backoff.next_duration();
289        assert_eq!(d1, Duration::from_millis(500));
290
291        // 2nd call: would be 500 * 3 = 1500ms but is capped to 1000ms
292        let d2 = backoff.next_duration();
293        assert_eq!(d2, Duration::from_secs(1));
294
295        // Subsequent calls should continue to return the max delay
296        let d3 = backoff.next_duration();
297        assert_eq!(d3, Duration::from_secs(1));
298    }
299
300    #[rstest]
301    fn test_current_delay_getter() {
302        let initial = Duration::from_millis(100);
303        let max = Duration::from_millis(1600);
304        let factor = 2.0;
305        let jitter = 0;
306        let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false).unwrap();
307
308        assert_eq!(backoff.current_delay(), initial);
309
310        let _ = backoff.next_duration();
311        assert_eq!(backoff.current_delay(), Duration::from_millis(200));
312
313        let _ = backoff.next_duration();
314        assert_eq!(backoff.current_delay(), Duration::from_millis(400));
315
316        backoff.reset();
317        assert_eq!(backoff.current_delay(), initial);
318    }
319
320    #[rstest]
321    fn test_validation_zero_initial_delay() {
322        let result = ExponentialBackoff::new(Duration::ZERO, Duration::from_secs(1), 2.0, 0, false);
323        assert!(result.is_err());
324        assert!(
325            result
326                .unwrap_err()
327                .to_string()
328                .contains("delay_initial must be non-zero")
329        );
330    }
331
332    #[rstest]
333    fn test_validation_max_less_than_initial() {
334        let result = ExponentialBackoff::new(
335            Duration::from_secs(1),
336            Duration::from_millis(500),
337            2.0,
338            0,
339            false,
340        );
341        assert!(result.is_err());
342        assert!(
343            result
344                .unwrap_err()
345                .to_string()
346                .contains("delay_max must be >= delay_initial")
347        );
348    }
349
350    #[rstest]
351    fn test_validation_factor_too_small() {
352        let result = ExponentialBackoff::new(
353            Duration::from_millis(100),
354            Duration::from_secs(1),
355            0.5,
356            0,
357            false,
358        );
359        assert!(result.is_err());
360        assert!(result.unwrap_err().to_string().contains("factor"));
361    }
362
363    #[rstest]
364    fn test_validation_factor_too_large() {
365        let result = ExponentialBackoff::new(
366            Duration::from_millis(100),
367            Duration::from_secs(1),
368            150.0,
369            0,
370            false,
371        );
372        assert!(result.is_err());
373        assert!(result.unwrap_err().to_string().contains("factor"));
374    }
375
376    #[rstest]
377    fn test_validation_delay_max_exceeds_u64_max_nanos() {
378        // Duration::from_nanos(u64::MAX) is approximately 584 years
379        // Try to create a backoff with delay_max exceeding this
380        let max_valid = Duration::from_nanos(u64::MAX);
381        let too_large = max_valid + Duration::from_nanos(1);
382
383        let result = ExponentialBackoff::new(Duration::from_millis(100), too_large, 2.0, 0, false);
384        assert!(result.is_err());
385        assert!(
386            result
387                .unwrap_err()
388                .to_string()
389                .contains("delay_max exceeds maximum representable duration")
390        );
391    }
392
393    #[rstest]
394    fn test_immediate_first() {
395        let initial = Duration::from_millis(100);
396        let max = Duration::from_millis(1600);
397        let factor = 2.0;
398        let jitter = 0;
399        let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, true).unwrap();
400
401        // The first call should yield an immediate (zero) delay
402        let d1 = backoff.next_duration();
403        assert_eq!(
404            d1,
405            Duration::ZERO,
406            "Expected immediate reconnect (zero delay) on first call"
407        );
408
409        // The next call should return the current delay (i.e. the base initial delay)
410        let d2 = backoff.next_duration();
411        assert_eq!(
412            d2, initial,
413            "Expected the delay to be the initial delay after immediate reconnect"
414        );
415
416        // Subsequent calls should continue with the exponential growth
417        let d3 = backoff.next_duration();
418        let expected = initial * 2; // 100ms * 2 = 200ms
419        assert_eq!(
420            d3, expected,
421            "Expected exponential growth from the initial delay"
422        );
423    }
424
425    #[rstest]
426    fn test_reset_restores_immediate_first() {
427        let initial = Duration::from_millis(100);
428        let max = Duration::from_millis(1600);
429        let factor = 2.0;
430        let jitter = 0;
431        let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, true).unwrap();
432
433        // Use immediate first
434        let d1 = backoff.next_duration();
435        assert_eq!(d1, Duration::ZERO);
436
437        // Now immediate_first should be disabled
438        let d2 = backoff.next_duration();
439        assert_eq!(d2, initial);
440
441        // Reset should restore immediate_first
442        backoff.reset();
443        let d3 = backoff.next_duration();
444        assert_eq!(
445            d3,
446            Duration::ZERO,
447            "Reset should restore immediate_first behavior"
448        );
449    }
450
451    #[rstest]
452    fn test_jitter_never_exceeds_max_delay() {
453        let initial = Duration::from_millis(100);
454        let max = Duration::from_secs(1);
455        let factor = 2.0;
456        let jitter = 500;
457
458        let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false).unwrap();
459
460        // Run backoff until it reaches the cap
461        while backoff.current_delay() < max {
462            backoff.next_duration();
463        }
464
465        // Now that we're at the cap, verify jitter doesn't push us over delay_max
466        for _ in 0..100 {
467            let delay = backoff.next_duration();
468            assert!(
469                delay <= max,
470                "Delay with jitter {delay:?} exceeded max {max:?}"
471            );
472        }
473    }
474
475    #[rstest]
476    fn test_jitter_spreads_delays_at_cap() {
477        // Regression: clamping after adding jitter collapsed the spread to a
478        // single value once the backoff saturated, re-synchronizing clients
479        // exactly during extended outages
480        let initial = Duration::from_millis(100);
481        let max = Duration::from_secs(1);
482        let mut backoff = ExponentialBackoff::new(initial, max, 2.0, 500, false).unwrap();
483
484        while backoff.current_delay() < max {
485            backoff.next_duration();
486        }
487
488        let mut distinct = std::collections::HashSet::new();
489        for _ in 0..100 {
490            distinct.insert(backoff.next_duration());
491        }
492
493        assert!(
494            distinct.len() >= 2,
495            "Jitter must keep spreading delays once the backoff saturates at the cap"
496        );
497    }
498
499    #[rstest]
500    fn test_jitter_wider_than_max_never_returns_zero_delay() {
501        // A jitter range wider than delay_max collapses the base to zero; the
502        // floor keeps non-immediate delays positive.
503        let max = Duration::from_millis(50);
504        let mut backoff =
505            ExponentialBackoff::new(Duration::from_millis(10), max, 2.0, 100, false).unwrap();
506
507        for _ in 0..200 {
508            let delay = backoff.next_duration();
509            assert!(
510                !delay.is_zero(),
511                "Non-immediate backoff delay must be positive"
512            );
513            assert!(delay <= max, "Delay {delay:?} exceeded max {max:?}");
514        }
515    }
516}