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