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