Skip to main content

radion_sdk/realtime/
reconnect.rs

1//! Exponential-backoff reconnect policy.
2
3use std::time::Duration;
4
5/// Options controlling exponential-backoff reconnect behaviour.
6#[derive(Debug, Clone, Copy)]
7pub struct ReconnectOptions {
8    /// Delay before the first retry.
9    pub initial_delay: Duration,
10    /// Upper bound on any single delay.
11    pub max_delay: Duration,
12    /// Multiplier applied to the delay after each attempt.
13    pub factor: f64,
14    /// Fraction of jitter (0–1) applied to each delay.
15    pub jitter: f64,
16}
17
18impl Default for ReconnectOptions {
19    fn default() -> Self {
20        Self {
21            initial_delay: Duration::from_millis(500),
22            max_delay: Duration::from_secs(30),
23            factor: 2.0,
24            jitter: 0.2,
25        }
26    }
27}
28
29/// Computes exponential-backoff delays for reconnect attempts. Owns only the
30/// timing policy; the client decides when to start and reset it.
31#[derive(Debug)]
32pub(crate) struct ReconnectPolicy {
33    options: ReconnectOptions,
34    attempt: u32,
35}
36
37impl ReconnectPolicy {
38    pub(crate) fn new(options: ReconnectOptions) -> Self {
39        Self {
40            options,
41            attempt: 0,
42        }
43    }
44
45    /// Number of retries since the last successful connection.
46    pub(crate) fn attempts(&self) -> u32 {
47        self.attempt
48    }
49
50    /// Advance the attempt counter and return the delay before the next attempt.
51    pub(crate) fn next_delay(&mut self) -> Duration {
52        let initial = self.options.initial_delay.as_secs_f64();
53        let max = self.options.max_delay.as_secs_f64();
54        let base = (initial * self.options.factor.powi(self.attempt as i32)).min(max);
55        self.attempt = self.attempt.saturating_add(1);
56        // Deterministic jitter keeps the math testable while spreading load.
57        let spread = base * self.options.jitter;
58        let delay = base - spread / 2.0 + spread * self.pseudo_jitter();
59        Duration::from_secs_f64(delay.max(0.0))
60    }
61
62    /// Clear backoff state after a successful connection or shutdown.
63    pub(crate) fn reset(&mut self) {
64        self.attempt = 0;
65    }
66
67    fn pseudo_jitter(&self) -> f64 {
68        // Cheap, dependency-free spread derived from the attempt count.
69        let x = (f64::from(self.attempt) * 12.9898).sin() * 43758.5453;
70        x - x.floor()
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn backoff_grows_and_caps_then_resets() {
80        let mut policy = ReconnectPolicy::new(ReconnectOptions::default());
81        let first = policy.next_delay();
82        let second = policy.next_delay();
83        assert!(second >= first, "delay should grow");
84        for _ in 0..20 {
85            let delay = policy.next_delay();
86            // Within max + jitter spread.
87            assert!(delay <= Duration::from_secs(35));
88        }
89        policy.reset();
90        assert_eq!(policy.attempts(), 0);
91    }
92}