Skip to main content

monocoque_core/
reconnect.rs

1//! Reconnection utilities with exponential backoff support.
2//!
3//! This module provides utilities for managing socket reconnection with
4//! exponential backoff, following libzmq patterns.
5
6use crate::options::SocketOptions;
7use std::time::Duration;
8
9/// Reconnection state tracker for managing connection attempts and backoff.
10///
11/// This helper tracks the number of reconnection attempts and calculates
12/// the appropriate backoff delay using exponential backoff.
13///
14/// # Example
15///
16/// ```rust
17/// use monocoque_core::reconnect::ReconnectState;
18/// use monocoque_core::options::SocketOptions;
19/// use std::time::Duration;
20///
21/// let options = SocketOptions::default()
22///     .with_reconnect_ivl(Duration::from_millis(100))
23///     .with_reconnect_ivl_max(Duration::from_secs(10));
24///
25/// let mut reconnect = ReconnectState::new(&options);
26///
27/// // First attempt uses base interval
28/// assert_eq!(reconnect.next_delay(), Duration::from_millis(100));
29///
30/// // Subsequent attempts use exponential backoff
31/// assert_eq!(reconnect.next_delay(), Duration::from_millis(200));
32/// assert_eq!(reconnect.next_delay(), Duration::from_millis(400));
33///
34/// // Reset on successful connection
35/// reconnect.reset();
36/// assert_eq!(reconnect.next_delay(), Duration::from_millis(100));
37/// ```
38#[derive(Debug, Clone)]
39pub struct ReconnectState {
40    /// Base reconnection interval
41    base_interval: Duration,
42    /// Maximum reconnection interval
43    max_interval: Duration,
44    /// Current reconnection attempt (0 = first attempt)
45    attempt: u32,
46    /// Current backoff interval
47    current_interval: Duration,
48}
49
50impl ReconnectState {
51    /// Create a new reconnection state tracker from socket options.
52    pub const fn new(options: &SocketOptions) -> Self {
53        Self {
54            base_interval: options.reconnect_ivl,
55            max_interval: options.reconnect_ivl_max,
56            attempt: 0,
57            current_interval: options.reconnect_ivl,
58        }
59    }
60
61    /// Get the delay for the next reconnection attempt.
62    ///
63    /// This calculates the exponential backoff delay based on the number
64    /// of previous attempts. The delay doubles with each attempt until
65    /// it reaches `reconnect_ivl_max`.
66    ///
67    /// # Returns
68    ///
69    /// The duration to wait before the next reconnection attempt.
70    pub fn next_delay(&mut self) -> Duration {
71        let delay = self.current_interval;
72
73        self.attempt += 1;
74
75        // libzmq semantics: reconnect_ivl_max == 0 disables exponential backoff
76        // and reconnects at the constant base interval. A max below the base is
77        // likewise treated as "hold at base" (there is no room to grow). Only
78        // when max > base do we apply exponential backoff capped at max.
79        if self.max_interval.is_zero() || self.max_interval <= self.base_interval {
80            self.current_interval = self.base_interval;
81        } else {
82            self.current_interval = self
83                .base_interval
84                .checked_mul(1_u32 << self.attempt.min(10))
85                .unwrap_or(self.max_interval)
86                .min(self.max_interval);
87        }
88
89        delay
90    }
91
92    /// Reset the reconnection state after a successful connection.
93    ///
94    /// This resets the attempt counter and interval back to the base values.
95    pub fn reset(&mut self) {
96        self.attempt = 0;
97        self.current_interval = self.base_interval;
98    }
99
100    /// Get the current attempt number.
101    #[inline]
102    #[must_use]
103    pub const fn attempt(&self) -> u32 {
104        self.attempt
105    }
106
107    /// Get the base reconnection interval.
108    #[inline]
109    #[must_use]
110    pub const fn base_interval(&self) -> Duration {
111        self.base_interval
112    }
113
114    /// Get the maximum reconnection interval.
115    #[inline]
116    #[must_use]
117    pub const fn max_interval(&self) -> Duration {
118        self.max_interval
119    }
120
121    /// Get the current reconnection interval.
122    #[inline]
123    #[must_use]
124    pub const fn current_interval(&self) -> Duration {
125        self.current_interval
126    }
127}
128
129/// Error type for reconnection operations.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum ReconnectError {
132    /// Maximum reconnection attempts reached
133    MaxAttemptsReached { attempts: u32 },
134    /// Connection failed with I/O error
135    ConnectionFailed { message: String },
136    /// Reconnection cancelled by user
137    Cancelled,
138}
139
140impl std::fmt::Display for ReconnectError {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        match self {
143            Self::MaxAttemptsReached { attempts } => {
144                write!(f, "Maximum reconnection attempts reached: {attempts}")
145            }
146            Self::ConnectionFailed { message } => {
147                write!(f, "Connection failed: {message}")
148            }
149            Self::Cancelled => {
150                write!(f, "Reconnection cancelled")
151            }
152        }
153    }
154}
155
156impl std::error::Error for ReconnectError {}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn test_exponential_backoff() {
164        let options = SocketOptions::default()
165            .with_reconnect_ivl(Duration::from_millis(100))
166            .with_reconnect_ivl_max(Duration::from_secs(10));
167
168        let mut state = ReconnectState::new(&options);
169
170        // First attempt: base interval
171        assert_eq!(state.next_delay(), Duration::from_millis(100));
172        assert_eq!(state.attempt(), 1);
173
174        // Second attempt: doubled
175        assert_eq!(state.next_delay(), Duration::from_millis(200));
176        assert_eq!(state.attempt(), 2);
177
178        // Third attempt: doubled again
179        assert_eq!(state.next_delay(), Duration::from_millis(400));
180        assert_eq!(state.attempt(), 3);
181
182        // Fourth attempt
183        assert_eq!(state.next_delay(), Duration::from_millis(800));
184        assert_eq!(state.attempt(), 4);
185    }
186
187    #[test]
188    fn test_max_interval_cap() {
189        let options = SocketOptions::default()
190            .with_reconnect_ivl(Duration::from_millis(100))
191            .with_reconnect_ivl_max(Duration::from_millis(500));
192
193        let mut state = ReconnectState::new(&options);
194
195        assert_eq!(state.next_delay(), Duration::from_millis(100));
196        assert_eq!(state.next_delay(), Duration::from_millis(200));
197        assert_eq!(state.next_delay(), Duration::from_millis(400));
198
199        // Should be capped at max
200        assert_eq!(state.next_delay(), Duration::from_millis(500));
201        assert_eq!(state.next_delay(), Duration::from_millis(500));
202    }
203
204    #[test]
205    fn test_next_delay_caps_before_duration_overflow() {
206        let options = SocketOptions::default()
207            .with_reconnect_ivl(Duration::MAX)
208            .with_reconnect_ivl_max(Duration::from_secs(1));
209
210        let mut state = ReconnectState::new(&options);
211        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
212            state.next_delay();
213        }));
214
215        assert!(result.is_ok());
216    }
217
218    #[test]
219    fn test_max_zero_holds_at_base_no_zero_delay_loop() {
220        // reconnect_ivl_max == 0 (the default) means "no exponential backoff,
221        // reconnect at the constant base interval" per libzmq. The previous
222        // implementation clamped the growing interval to max==0, producing a
223        // 100ms, 0, 0, 0... hot-loop that hammered connect().
224        let options = SocketOptions::default()
225            .with_reconnect_ivl(Duration::from_millis(100))
226            .with_reconnect_ivl_max(Duration::ZERO);
227
228        let mut state = ReconnectState::new(&options);
229
230        for _ in 0..10 {
231            assert_eq!(
232                state.next_delay(),
233                Duration::from_millis(100),
234                "max=0 must hold the delay at the base interval, never 0"
235            );
236        }
237    }
238
239    #[test]
240    fn test_max_below_base_holds_at_base() {
241        // A max smaller than the base leaves no room to grow: hold at base
242        // rather than clamping down to the smaller max.
243        let options = SocketOptions::default()
244            .with_reconnect_ivl(Duration::from_millis(100))
245            .with_reconnect_ivl_max(Duration::from_millis(50));
246
247        let mut state = ReconnectState::new(&options);
248
249        for _ in 0..5 {
250            assert_eq!(state.next_delay(), Duration::from_millis(100));
251        }
252    }
253
254    #[test]
255    fn test_reset() {
256        let options = SocketOptions::default()
257            .with_reconnect_ivl(Duration::from_millis(100))
258            .with_reconnect_ivl_max(Duration::from_secs(10));
259
260        let mut state = ReconnectState::new(&options);
261
262        // Make some attempts
263        state.next_delay();
264        state.next_delay();
265        state.next_delay();
266        assert_eq!(state.attempt(), 3);
267
268        // Reset
269        state.reset();
270        assert_eq!(state.attempt(), 0);
271        assert_eq!(state.next_delay(), Duration::from_millis(100));
272    }
273
274    #[test]
275    fn test_state_accessors() {
276        let options = SocketOptions::default()
277            .with_reconnect_ivl(Duration::from_millis(250))
278            .with_reconnect_ivl_max(Duration::from_secs(5));
279
280        let state = ReconnectState::new(&options);
281
282        assert_eq!(state.base_interval(), Duration::from_millis(250));
283        assert_eq!(state.max_interval(), Duration::from_secs(5));
284        assert_eq!(state.current_interval(), Duration::from_millis(250));
285        assert_eq!(state.attempt(), 0);
286    }
287}