Skip to main content

async_snmp/client/
retry.rs

1//! Retry configuration for SNMP requests.
2//!
3//! This module provides configurable retry strategies including fixed delay
4//! and exponential backoff with jitter.
5
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::Duration;
8
9/// Retry configuration for SNMP requests.
10///
11/// Controls how the client handles timeouts on UDP transports. TCP transports
12/// ignore retry configuration since the transport layer handles reliability.
13///
14/// # Examples
15///
16/// ```rust
17/// use async_snmp::Retry;
18/// use std::time::Duration;
19///
20/// // No retries
21/// let retry = Retry::none();
22///
23/// // Fixed delay between retries
24/// let retry = Retry::fixed(3, Duration::from_millis(200));
25///
26/// // Exponential backoff with jitter (1s, 2s, 4s, 5s, 5s)
27/// let retry = Retry::exponential(5)
28///     .max_delay(Duration::from_secs(5))
29///     .jitter(0.25)
30///     .build();
31/// ```
32#[derive(Clone, Debug)]
33pub struct Retry {
34    /// Maximum number of retry attempts (0 = no retries, request sent once)
35    pub max_attempts: u32,
36    /// Backoff strategy between retries
37    pub backoff: Backoff,
38}
39
40/// Backoff strategy between retry attempts.
41#[derive(Clone, Copy, Debug, Default)]
42pub enum Backoff {
43    /// No delay between retries (immediate retry on timeout).
44    #[default]
45    None,
46
47    /// Fixed delay between each retry attempt.
48    Fixed {
49        /// Delay before each retry
50        delay: Duration,
51    },
52
53    /// Exponential backoff: delay doubles after each attempt.
54    ///
55    /// With jitter enabled (recommended), the actual delay is randomized
56    /// within a range to prevent synchronized retries from multiple clients.
57    Exponential {
58        /// Initial delay before first retry
59        initial: Duration,
60        /// Maximum delay cap
61        max: Duration,
62        /// Jitter factor (0.0-1.0). E.g., 0.25 means ±25% randomization.
63        jitter: f64,
64    },
65}
66
67impl Default for Retry {
68    /// Default: 3 retries with 1-second fixed delay between attempts.
69    fn default() -> Self {
70        Self {
71            max_attempts: 3,
72            backoff: Backoff::Fixed {
73                delay: Duration::from_secs(1),
74            },
75        }
76    }
77}
78
79impl Retry {
80    /// No retries - request is sent once and fails on timeout.
81    #[must_use]
82    pub fn none() -> Self {
83        Self {
84            max_attempts: 0,
85            backoff: Backoff::None,
86        }
87    }
88
89    /// Fixed delay between retries.
90    ///
91    /// # Arguments
92    ///
93    /// * `attempts` - Maximum number of retry attempts
94    /// * `delay` - Fixed delay before each retry
95    #[must_use]
96    pub fn fixed(attempts: u32, delay: Duration) -> Self {
97        Self {
98            max_attempts: attempts,
99            backoff: Backoff::Fixed { delay },
100        }
101    }
102
103    /// Start building an exponential backoff retry configuration.
104    ///
105    /// Returns a [`RetryBuilder`] for configuring the backoff parameters.
106    ///
107    /// # Arguments
108    ///
109    /// * `attempts` - Maximum number of retry attempts
110    ///
111    /// # Example
112    ///
113    /// ```rust
114    /// use async_snmp::Retry;
115    /// use std::time::Duration;
116    ///
117    /// let retry = Retry::exponential(5)
118    ///     .max_delay(Duration::from_secs(5))
119    ///     .jitter(0.25)
120    ///     .build();
121    /// ```
122    #[must_use]
123    pub fn exponential(attempts: u32) -> RetryBuilder {
124        RetryBuilder {
125            max_attempts: attempts,
126            ..Default::default()
127        }
128    }
129
130    /// Compute the delay before the next retry attempt.
131    ///
132    /// Returns `Duration::ZERO` for `Backoff::None`.
133    #[must_use]
134    pub fn compute_delay(&self, attempt: u32) -> Duration {
135        match &self.backoff {
136            Backoff::None => Duration::ZERO,
137            Backoff::Fixed { delay } => *delay,
138            Backoff::Exponential {
139                initial,
140                max,
141                jitter,
142            } => {
143                // Exponential: initial * 2^attempt, capped at max
144                // Clamp attempt to prevent overflow (32 is more than enough)
145                let shift = attempt.min(31);
146                let multiplier = 1u32.checked_shl(shift).unwrap_or(u32::MAX);
147                let base = initial.saturating_mul(multiplier);
148                let capped = base.min(*max);
149
150                // Apply jitter
151                let factor = jitter_factor(*jitter);
152                Duration::from_secs_f64(capped.as_secs_f64() * factor)
153            }
154        }
155    }
156}
157
158/// Builder for exponential backoff retry configuration.
159#[derive(Debug, Clone)]
160pub struct RetryBuilder {
161    max_attempts: u32,
162    initial: Duration,
163    max: Duration,
164    jitter: f64,
165}
166
167impl Default for RetryBuilder {
168    fn default() -> Self {
169        Self {
170            max_attempts: 3,
171            initial: Duration::from_secs(1),
172            max: Duration::from_secs(5),
173            jitter: 0.25,
174        }
175    }
176}
177
178impl RetryBuilder {
179    /// Set the initial delay before the first retry (default: 1 second).
180    #[must_use]
181    pub fn initial_delay(mut self, delay: Duration) -> Self {
182        self.initial = delay;
183        self
184    }
185
186    /// Set the maximum delay cap (default: 5 seconds).
187    #[must_use]
188    pub fn max_delay(mut self, delay: Duration) -> Self {
189        self.max = delay;
190        self
191    }
192
193    /// Set the jitter factor (default: 0.25, meaning ±25% randomization).
194    ///
195    /// Jitter helps prevent synchronized retries when multiple clients
196    /// experience timeouts simultaneously.
197    ///
198    /// The value is clamped to [0.0, 1.0].
199    #[must_use]
200    pub fn jitter(mut self, jitter: f64) -> Self {
201        self.jitter = jitter.clamp(0.0, 1.0);
202        self
203    }
204
205    /// Build the [`Retry`] configuration.
206    #[must_use]
207    pub fn build(self) -> Retry {
208        Retry {
209            max_attempts: self.max_attempts,
210            backoff: Backoff::Exponential {
211                initial: self.initial,
212                max: self.max,
213                jitter: self.jitter,
214            },
215        }
216    }
217}
218
219impl From<RetryBuilder> for Retry {
220    fn from(builder: RetryBuilder) -> Self {
221        builder.build()
222    }
223}
224
225/// Global counter for jitter generation.
226static JITTER_COUNTER: AtomicU64 = AtomicU64::new(0);
227
228/// Compute a jitter factor in the range [1-jitter, 1+jitter].
229///
230/// Uses a multiplicative hash of an atomic counter to generate pseudo-random
231/// values. This is sufficient for retry desynchronization without requiring
232/// true randomness.
233#[allow(
234    clippy::cast_precision_loss,
235    reason = "u64->f64 cast is intentional part of hash-like algorithm"
236)]
237fn jitter_factor(jitter: f64) -> f64 {
238    if jitter <= 0.0 {
239        return 1.0;
240    }
241    // Multiplicative hash of counter (Knuth's method)
242    let counter = JITTER_COUNTER.fetch_add(1, Ordering::Relaxed);
243    let hash = counter.wrapping_mul(0x5851_f42d_4c95_7f2d);
244    // Convert to [0, 1) range using upper bits (better distribution)
245    let random = (hash >> 11) as f64 / ((1u64 << 53) as f64);
246    // Return factor in [1-jitter, 1+jitter]
247    1.0 + (random - 0.5) * 2.0 * jitter
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn test_retry_none() {
256        let retry = Retry::none();
257        assert_eq!(retry.max_attempts, 0);
258        assert!(matches!(retry.backoff, Backoff::None));
259    }
260
261    #[test]
262    fn test_retry_default() {
263        let retry = Retry::default();
264        assert_eq!(retry.max_attempts, 3);
265        assert!(
266            matches!(retry.backoff, Backoff::Fixed { delay } if delay == Duration::from_secs(1))
267        );
268    }
269
270    #[test]
271    fn test_retry_fixed() {
272        let retry = Retry::fixed(5, Duration::from_millis(200));
273        assert_eq!(retry.max_attempts, 5);
274        assert!(
275            matches!(retry.backoff, Backoff::Fixed { delay } if delay == Duration::from_millis(200))
276        );
277    }
278
279    #[test]
280    fn test_retry_exponential_builder() {
281        let retry = Retry::exponential(4)
282            .initial_delay(Duration::from_millis(50))
283            .max_delay(Duration::from_secs(1))
284            .jitter(0.1)
285            .build();
286
287        assert_eq!(retry.max_attempts, 4);
288        match retry.backoff {
289            Backoff::Exponential {
290                initial,
291                max,
292                jitter,
293            } => {
294                assert_eq!(initial, Duration::from_millis(50));
295                assert_eq!(max, Duration::from_secs(1));
296                assert!((jitter - 0.1).abs() < f64::EPSILON);
297            }
298            _ => panic!("expected Exponential"),
299        }
300    }
301
302    #[test]
303    fn test_jitter_clamped() {
304        let retry = Retry::exponential(1).jitter(-0.5).build();
305        match retry.backoff {
306            Backoff::Exponential { jitter, .. } => assert_eq!(jitter, 0.0),
307            _ => panic!("expected Exponential"),
308        }
309
310        let retry = Retry::exponential(1).jitter(2.0).build();
311        match retry.backoff {
312            Backoff::Exponential { jitter, .. } => assert_eq!(jitter, 1.0),
313            _ => panic!("expected Exponential"),
314        }
315    }
316
317    #[test]
318    fn test_compute_delay_none() {
319        let retry = Retry::none();
320        assert_eq!(retry.compute_delay(0), Duration::ZERO);
321        assert_eq!(retry.compute_delay(5), Duration::ZERO);
322    }
323
324    #[test]
325    fn test_compute_delay_default() {
326        let retry = Retry::default();
327        assert_eq!(retry.compute_delay(0), Duration::from_secs(1));
328        assert_eq!(retry.compute_delay(5), Duration::from_secs(1));
329    }
330
331    #[test]
332    fn test_compute_delay_fixed() {
333        let retry = Retry::fixed(3, Duration::from_millis(100));
334        assert_eq!(retry.compute_delay(0), Duration::from_millis(100));
335        assert_eq!(retry.compute_delay(1), Duration::from_millis(100));
336        assert_eq!(retry.compute_delay(10), Duration::from_millis(100));
337    }
338
339    #[test]
340    fn test_compute_delay_exponential_no_jitter() {
341        let retry = Retry::exponential(5)
342            .initial_delay(Duration::from_millis(100))
343            .max_delay(Duration::from_secs(10))
344            .jitter(0.0)
345            .build();
346
347        assert_eq!(retry.compute_delay(0), Duration::from_millis(100));
348        assert_eq!(retry.compute_delay(1), Duration::from_millis(200));
349        assert_eq!(retry.compute_delay(2), Duration::from_millis(400));
350        assert_eq!(retry.compute_delay(3), Duration::from_millis(800));
351    }
352
353    #[test]
354    fn test_compute_delay_exponential_capped() {
355        let retry = Retry::exponential(10)
356            .initial_delay(Duration::from_millis(100))
357            .max_delay(Duration::from_millis(500))
358            .jitter(0.0)
359            .build();
360
361        assert_eq!(retry.compute_delay(0), Duration::from_millis(100));
362        assert_eq!(retry.compute_delay(1), Duration::from_millis(200));
363        assert_eq!(retry.compute_delay(2), Duration::from_millis(400));
364        // Should be capped at 500ms
365        assert_eq!(retry.compute_delay(3), Duration::from_millis(500));
366        assert_eq!(retry.compute_delay(10), Duration::from_millis(500));
367    }
368
369    #[test]
370    fn test_compute_delay_exponential_with_jitter() {
371        let retry = Retry::exponential(3)
372            .initial_delay(Duration::from_millis(100))
373            .max_delay(Duration::from_secs(1))
374            .jitter(0.25)
375            .build();
376
377        // With jitter, delay should be in [75ms, 125ms] for attempt 0
378        // Run multiple times to verify it's in range
379        for _ in 0..10 {
380            let delay = retry.compute_delay(0);
381            let millis = delay.as_millis();
382            assert!((75..=125).contains(&millis), "delay was {millis}ms");
383        }
384    }
385
386    #[test]
387    fn test_jitter_factor_range() {
388        // Test that jitter_factor produces values in expected range
389        for _ in 0..100 {
390            let factor = jitter_factor(0.5);
391            assert!((0.5..=1.5).contains(&factor), "factor was {factor}");
392        }
393    }
394
395    #[test]
396    fn test_jitter_factor_zero() {
397        assert_eq!(jitter_factor(0.0), 1.0);
398        assert_eq!(jitter_factor(-0.1), 1.0);
399    }
400
401    #[test]
402    fn test_from_builder() {
403        let builder = Retry::exponential(2).initial_delay(Duration::from_millis(50));
404        let retry: Retry = builder.into();
405        assert_eq!(retry.max_attempts, 2);
406    }
407}