Skip to main content

truffle_core/session/
reconnect.rs

1//! Exponential backoff tracker for reconnection attempts.
2
3use std::time::{Duration, Instant};
4
5/// Exponential backoff tracker for reconnection attempts.
6/// Starts at 100ms, doubles each attempt, caps at 30s.
7#[derive(Debug)]
8pub struct ReconnectBackoff {
9    attempt: u32,
10    base: Duration,
11    max: Duration,
12    last_attempt: Option<Instant>,
13}
14
15impl ReconnectBackoff {
16    /// Create a new backoff tracker with default parameters.
17    /// Base delay: 100ms, max delay: 30s.
18    pub fn new() -> Self {
19        Self {
20            attempt: 0,
21            base: Duration::from_millis(100),
22            max: Duration::from_secs(30),
23            last_attempt: None,
24        }
25    }
26
27    /// Returns how long to wait before the next attempt.
28    /// Returns `None` if the backoff period hasn't elapsed yet
29    /// (i.e., the caller must wait before retrying).
30    /// Returns `Some(Duration::ZERO)` if the caller may retry immediately.
31    pub fn should_retry(&self) -> Option<Duration> {
32        if self.attempt == 0 {
33            return Some(Duration::ZERO);
34        }
35
36        let delay = self.current_delay();
37
38        match self.last_attempt {
39            Some(last) => {
40                let elapsed = last.elapsed();
41                if elapsed >= delay {
42                    Some(Duration::ZERO)
43                } else {
44                    // Backoff period not yet elapsed — return remaining wait
45                    None
46                }
47            }
48            // No previous attempt recorded, allow retry
49            None => Some(Duration::ZERO),
50        }
51    }
52
53    /// The retry-after duration: how long the caller must wait from now.
54    /// Returns `Duration::ZERO` if retrying is allowed immediately.
55    pub fn retry_after(&self) -> Duration {
56        if self.attempt == 0 {
57            return Duration::ZERO;
58        }
59
60        let delay = self.current_delay();
61
62        match self.last_attempt {
63            Some(last) => {
64                let elapsed = last.elapsed();
65                if elapsed >= delay {
66                    Duration::ZERO
67                } else {
68                    delay - elapsed
69                }
70            }
71            None => Duration::ZERO,
72        }
73    }
74
75    /// Record a successful connection -- resets the backoff.
76    pub fn success(&mut self) {
77        self.attempt = 0;
78        self.last_attempt = None;
79    }
80
81    /// Record a failed attempt -- increases the backoff.
82    pub fn failure(&mut self) {
83        self.last_attempt = Some(Instant::now());
84        self.attempt = self.attempt.saturating_add(1);
85    }
86
87    /// Current delay for the current attempt number.
88    fn current_delay(&self) -> Duration {
89        // 100ms * 2^(attempt-1), capped at 30s
90        let shift = (self.attempt.saturating_sub(1)).min(31);
91        let delay_ms = self.base.as_millis() as u64 * (1u64 << shift);
92        let delay = Duration::from_millis(delay_ms);
93        delay.min(self.max)
94    }
95}
96
97impl Default for ReconnectBackoff {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn test_initial_state_allows_retry() {
109        let backoff = ReconnectBackoff::new();
110        assert!(backoff.should_retry().is_some());
111        assert_eq!(backoff.retry_after(), Duration::ZERO);
112    }
113
114    #[test]
115    fn test_failure_increases_delay() {
116        let mut backoff = ReconnectBackoff::new();
117
118        backoff.failure(); // attempt 1 → 100ms
119        assert_eq!(backoff.current_delay(), Duration::from_millis(100));
120
121        backoff.last_attempt = Some(Instant::now() - Duration::from_secs(60));
122        backoff.failure(); // attempt 2 → 200ms
123        assert_eq!(backoff.current_delay(), Duration::from_millis(200));
124
125        backoff.last_attempt = Some(Instant::now() - Duration::from_secs(60));
126        backoff.failure(); // attempt 3 → 400ms
127        assert_eq!(backoff.current_delay(), Duration::from_millis(400));
128    }
129
130    #[test]
131    fn test_delay_caps_at_max() {
132        let mut backoff = ReconnectBackoff::new();
133        for _ in 0..30 {
134            backoff.last_attempt = Some(Instant::now() - Duration::from_secs(60));
135            backoff.failure();
136        }
137        assert!(backoff.current_delay() <= Duration::from_secs(30));
138    }
139
140    #[test]
141    fn test_success_resets() {
142        let mut backoff = ReconnectBackoff::new();
143        backoff.failure();
144        backoff.failure();
145        backoff.failure();
146
147        backoff.success();
148        assert_eq!(backoff.attempt, 0);
149        assert!(backoff.last_attempt.is_none());
150        assert!(backoff.should_retry().is_some());
151    }
152
153    #[test]
154    fn test_should_retry_blocks_during_backoff() {
155        let mut backoff = ReconnectBackoff::new();
156        backoff.failure(); // attempt 1, last_attempt = now
157
158        // Immediately after failure, should_retry returns None (must wait)
159        assert!(backoff.should_retry().is_none());
160    }
161}