nautilus_network/
backoff.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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 rand::Rng;
27
28#[derive(Clone, Debug)]
29pub struct ExponentialBackoff {
30    /// The initial backoff delay.
31    delay_initial: Duration,
32    /// The maximum delay to cap the backoff.
33    delay_max: Duration,
34    /// The current backoff delay.
35    delay_current: Duration,
36    /// The factor to multiply the delay on each iteration.
37    factor: f64,
38    /// The maximum random jitter to add (in milliseconds).
39    jitter_ms: u64,
40    /// If true, the first call to `next()` returns zero delay (immediate reconnect).
41    immediate_first: bool,
42}
43
44/// An exponential backoff mechanism with optional jitter and immediate-first behavior.
45///
46/// This struct computes successive delays for reconnect attempts.
47/// It starts from an initial delay and multiplies it by a factor on each iteration,
48/// capping the delay at a maximum value. Random jitter is added (up to a configured
49/// maximum) to the delay. When `immediate_first` is true, the first call to `next_duration`
50/// returns zero delay, triggering an immediate reconnect, after which the immediate flag is disabled.
51impl ExponentialBackoff {
52    /// Creates a new [`ExponentialBackoff]` instance.
53    #[must_use]
54    pub const fn new(
55        delay_initial: Duration,
56        delay_max: Duration,
57        factor: f64,
58        jitter_ms: u64,
59        immediate_first: bool,
60    ) -> Self {
61        Self {
62            delay_initial,
63            delay_max,
64            delay_current: delay_initial,
65            factor,
66            jitter_ms,
67            immediate_first,
68        }
69    }
70
71    /// Return the next backoff delay with jitter and update the internal state.
72    ///
73    /// If the `immediate_first` flag is set and this is the first call (i.e. the current
74    /// delay equals the initial delay), it returns `Duration::ZERO` to trigger an immediate
75    /// reconnect and disables the immediate behavior for subsequent calls.
76    pub fn next_duration(&mut self) -> Duration {
77        if self.immediate_first && self.delay_current == self.delay_initial {
78            self.immediate_first = false;
79            return Duration::ZERO;
80        }
81
82        // Generate random jitter
83        let jitter = rand::rng().random_range(0..=self.jitter_ms);
84        let delay_with_jitter = self.delay_current + Duration::from_millis(jitter);
85
86        // Prepare the next delay
87        let current_nanos = self.delay_current.as_nanos();
88        let max_nanos = self.delay_max.as_nanos() as u64;
89        let next_nanos = (current_nanos as f64 * self.factor) as u64;
90        self.delay_current = Duration::from_nanos(std::cmp::min(next_nanos, max_nanos));
91
92        delay_with_jitter
93    }
94
95    /// Reset the backoff to its initial state.
96    pub fn reset(&mut self) {
97        self.delay_current = self.delay_initial;
98    }
99
100    /// Returns the current base delay without jitter.
101    /// This represents the delay that would be used as the base for the next call to `next()`,
102    /// before any jitter is applied.
103    #[must_use]
104    pub const fn current_delay(&self) -> Duration {
105        self.delay_current
106    }
107}
108
109////////////////////////////////////////////////////////////////////////////////
110// Tests
111////////////////////////////////////////////////////////////////////////////////
112#[cfg(test)]
113mod tests {
114    use std::time::Duration;
115
116    use rstest::rstest;
117
118    use super::*;
119
120    #[rstest]
121    fn test_no_jitter_exponential_growth() {
122        let initial = Duration::from_millis(100);
123        let max = Duration::from_millis(1600);
124        let factor = 2.0;
125        let jitter = 0;
126        let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false);
127
128        // 1st call returns the initial delay
129        let d1 = backoff.next_duration();
130        assert_eq!(d1, Duration::from_millis(100));
131
132        // 2nd call: current becomes 200ms
133        let d2 = backoff.next_duration();
134        assert_eq!(d2, Duration::from_millis(200));
135
136        // 3rd call: current becomes 400ms
137        let d3 = backoff.next_duration();
138        assert_eq!(d3, Duration::from_millis(400));
139
140        // 4th call: current becomes 800ms
141        let d4 = backoff.next_duration();
142        assert_eq!(d4, Duration::from_millis(800));
143
144        // 5th call: current would be 1600ms (800 * 2) which is within the cap
145        let d5 = backoff.next_duration();
146        assert_eq!(d5, Duration::from_millis(1600));
147
148        // 6th call: should still be capped at 1600ms
149        let d6 = backoff.next_duration();
150        assert_eq!(d6, Duration::from_millis(1600));
151    }
152
153    #[rstest]
154    fn test_reset() {
155        let initial = Duration::from_millis(100);
156        let max = Duration::from_millis(1600);
157        let factor = 2.0;
158        let jitter = 0;
159        let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false);
160
161        // Call next() once so that the internal state updates
162        let _ = backoff.next_duration(); // current_delay becomes 200ms
163        backoff.reset();
164        let d = backoff.next_duration();
165        // After reset, the next delay should be the initial delay (100ms)
166        assert_eq!(d, Duration::from_millis(100));
167    }
168
169    #[rstest]
170    fn test_jitter_within_bounds() {
171        let initial = Duration::from_millis(100);
172        let max = Duration::from_millis(1000);
173        let factor = 2.0;
174        let jitter = 50;
175        // Run several iterations to ensure that jitter stays within bounds
176        for _ in 0..10 {
177            let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false);
178            // Capture the expected base delay before jitter is applied
179            let base = backoff.delay_current;
180            let delay = backoff.next_duration();
181            // The returned delay must be at least the base delay and at most base + jitter
182            let min_expected = base;
183            let max_expected = base + Duration::from_millis(jitter);
184            assert!(
185                delay >= min_expected,
186                "Delay {delay:?} is less than expected minimum {min_expected:?}"
187            );
188            assert!(
189                delay <= max_expected,
190                "Delay {delay:?} exceeds expected maximum {max_expected:?}"
191            );
192        }
193    }
194
195    #[rstest]
196    fn test_factor_less_than_two() {
197        let initial = Duration::from_millis(100);
198        let max = Duration::from_millis(200);
199        let factor = 1.5;
200        let jitter = 0;
201        let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false);
202
203        // First call returns 100ms
204        let d1 = backoff.next_duration();
205        assert_eq!(d1, Duration::from_millis(100));
206
207        // Second call: current_delay becomes 100 * 1.5 = 150ms
208        let d2 = backoff.next_duration();
209        assert_eq!(d2, Duration::from_millis(150));
210
211        // Third call: current_delay becomes 150 * 1.5 = 225ms, but capped to 200ms
212        let d3 = backoff.next_duration();
213        assert_eq!(d3, Duration::from_millis(200));
214
215        // Fourth call: remains at the max of 200ms
216        let d4 = backoff.next_duration();
217        assert_eq!(d4, Duration::from_millis(200));
218    }
219
220    #[rstest]
221    fn test_max_delay_is_respected() {
222        let initial = Duration::from_millis(500);
223        let max = Duration::from_millis(1000);
224        let factor = 3.0;
225        let jitter = 0;
226        let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false);
227
228        // 1st call returns 500ms
229        let d1 = backoff.next_duration();
230        assert_eq!(d1, Duration::from_millis(500));
231
232        // 2nd call: would be 500 * 3 = 1500ms but is capped to 1000ms
233        let d2 = backoff.next_duration();
234        assert_eq!(d2, Duration::from_millis(1000));
235
236        // Subsequent calls should continue to return the max delay
237        let d3 = backoff.next_duration();
238        assert_eq!(d3, Duration::from_millis(1000));
239    }
240
241    #[rstest]
242    fn test_current_delay_getter() {
243        let initial = Duration::from_millis(100);
244        let max = Duration::from_millis(1600);
245        let factor = 2.0;
246        let jitter = 0;
247        let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false);
248
249        assert_eq!(backoff.current_delay(), initial);
250
251        let _ = backoff.next_duration();
252        assert_eq!(backoff.current_delay(), Duration::from_millis(200));
253
254        let _ = backoff.next_duration();
255        assert_eq!(backoff.current_delay(), Duration::from_millis(400));
256
257        backoff.reset();
258        assert_eq!(backoff.current_delay(), initial);
259    }
260
261    #[rstest]
262    fn test_immediate_first() {
263        let initial = Duration::from_millis(100);
264        let max = Duration::from_millis(1600);
265        let factor = 2.0;
266        let jitter = 0;
267        let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, true);
268
269        // The first call should yield an immediate (zero) delay
270        let d1 = backoff.next_duration();
271        assert_eq!(
272            d1,
273            Duration::ZERO,
274            "Expected immediate reconnect (zero delay) on first call"
275        );
276
277        // The next call should return the current delay (i.e. the base initial delay)
278        let d2 = backoff.next_duration();
279        assert_eq!(
280            d2, initial,
281            "Expected the delay to be the initial delay after immediate reconnect"
282        );
283
284        // Subsequent calls should continue with the exponential growth
285        let d3 = backoff.next_duration();
286        let expected = initial * 2; // 100ms * 2 = 200ms
287        assert_eq!(
288            d3, expected,
289            "Expected exponential growth from the initial delay"
290        );
291    }
292}