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, 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    pub fn none() -> Self {
82        Self {
83            max_attempts: 0,
84            backoff: Backoff::None,
85        }
86    }
87
88    /// Fixed delay between retries.
89    ///
90    /// # Arguments
91    ///
92    /// * `attempts` - Maximum number of retry attempts
93    /// * `delay` - Fixed delay before each retry
94    pub fn fixed(attempts: u32, delay: Duration) -> Self {
95        Self {
96            max_attempts: attempts,
97            backoff: Backoff::Fixed { delay },
98        }
99    }
100
101    /// Start building an exponential backoff retry configuration.
102    ///
103    /// Returns a [`RetryBuilder`] for configuring the backoff parameters.
104    ///
105    /// # Arguments
106    ///
107    /// * `attempts` - Maximum number of retry attempts
108    ///
109    /// # Example
110    ///
111    /// ```rust
112    /// use async_snmp::Retry;
113    /// use std::time::Duration;
114    ///
115    /// let retry = Retry::exponential(5)
116    ///     .max_delay(Duration::from_secs(5))
117    ///     .jitter(0.25)
118    ///     .build();
119    /// ```
120    pub fn exponential(attempts: u32) -> RetryBuilder {
121        RetryBuilder {
122            max_attempts: attempts,
123            ..Default::default()
124        }
125    }
126
127    /// Compute the delay before the next retry attempt.
128    ///
129    /// Returns `Duration::ZERO` for `Backoff::None`.
130    pub fn compute_delay(&self, attempt: u32) -> Duration {
131        match &self.backoff {
132            Backoff::None => Duration::ZERO,
133            Backoff::Fixed { delay } => *delay,
134            Backoff::Exponential {
135                initial,
136                max,
137                jitter,
138            } => {
139                // Exponential: initial * 2^attempt, capped at max
140                // Clamp attempt to prevent overflow (32 is more than enough)
141                let shift = attempt.min(31);
142                let multiplier = 1u32.checked_shl(shift).unwrap_or(u32::MAX);
143                let base = initial.saturating_mul(multiplier);
144                let capped = base.min(*max);
145
146                // Apply jitter
147                let factor = jitter_factor(*jitter);
148                Duration::from_secs_f64(capped.as_secs_f64() * factor)
149            }
150        }
151    }
152}
153
154/// Builder for exponential backoff retry configuration.
155pub struct RetryBuilder {
156    max_attempts: u32,
157    initial: Duration,
158    max: Duration,
159    jitter: f64,
160}
161
162impl Default for RetryBuilder {
163    fn default() -> Self {
164        Self {
165            max_attempts: 3,
166            initial: Duration::from_secs(1),
167            max: Duration::from_secs(5),
168            jitter: 0.25,
169        }
170    }
171}
172
173impl RetryBuilder {
174    /// Set the initial delay before the first retry (default: 1 second).
175    pub fn initial_delay(mut self, delay: Duration) -> Self {
176        self.initial = delay;
177        self
178    }
179
180    /// Set the maximum delay cap (default: 5 seconds).
181    pub fn max_delay(mut self, delay: Duration) -> Self {
182        self.max = delay;
183        self
184    }
185
186    /// Set the jitter factor (default: 0.25, meaning ±25% randomization).
187    ///
188    /// Jitter helps prevent synchronized retries when multiple clients
189    /// experience timeouts simultaneously.
190    ///
191    /// The value is clamped to [0.0, 1.0].
192    pub fn jitter(mut self, jitter: f64) -> Self {
193        self.jitter = jitter.clamp(0.0, 1.0);
194        self
195    }
196
197    /// Build the [`Retry`] configuration.
198    pub fn build(self) -> Retry {
199        Retry {
200            max_attempts: self.max_attempts,
201            backoff: Backoff::Exponential {
202                initial: self.initial,
203                max: self.max,
204                jitter: self.jitter,
205            },
206        }
207    }
208}
209
210impl From<RetryBuilder> for Retry {
211    fn from(builder: RetryBuilder) -> Self {
212        builder.build()
213    }
214}
215
216/// Global counter for jitter generation.
217static JITTER_COUNTER: AtomicU64 = AtomicU64::new(0);
218
219/// Compute a jitter factor in the range [1-jitter, 1+jitter].
220///
221/// Uses a multiplicative hash of an atomic counter to generate pseudo-random
222/// values. This is sufficient for retry desynchronization without requiring
223/// true randomness.
224fn jitter_factor(jitter: f64) -> f64 {
225    if jitter <= 0.0 {
226        return 1.0;
227    }
228    // Multiplicative hash of counter (Knuth's method)
229    let counter = JITTER_COUNTER.fetch_add(1, Ordering::Relaxed);
230    let hash = counter.wrapping_mul(0x5851f42d4c957f2d);
231    // Convert to [0, 1) range using upper bits (better distribution)
232    let random = (hash >> 11) as f64 / ((1u64 << 53) as f64);
233    // Return factor in [1-jitter, 1+jitter]
234    1.0 + (random - 0.5) * 2.0 * jitter
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn test_retry_none() {
243        let retry = Retry::none();
244        assert_eq!(retry.max_attempts, 0);
245        assert!(matches!(retry.backoff, Backoff::None));
246    }
247
248    #[test]
249    fn test_retry_default() {
250        let retry = Retry::default();
251        assert_eq!(retry.max_attempts, 3);
252        assert!(
253            matches!(retry.backoff, Backoff::Fixed { delay } if delay == Duration::from_secs(1))
254        );
255    }
256
257    #[test]
258    fn test_retry_fixed() {
259        let retry = Retry::fixed(5, Duration::from_millis(200));
260        assert_eq!(retry.max_attempts, 5);
261        assert!(
262            matches!(retry.backoff, Backoff::Fixed { delay } if delay == Duration::from_millis(200))
263        );
264    }
265
266    #[test]
267    fn test_retry_exponential_builder() {
268        let retry = Retry::exponential(4)
269            .initial_delay(Duration::from_millis(50))
270            .max_delay(Duration::from_secs(1))
271            .jitter(0.1)
272            .build();
273
274        assert_eq!(retry.max_attempts, 4);
275        match retry.backoff {
276            Backoff::Exponential {
277                initial,
278                max,
279                jitter,
280            } => {
281                assert_eq!(initial, Duration::from_millis(50));
282                assert_eq!(max, Duration::from_secs(1));
283                assert!((jitter - 0.1).abs() < f64::EPSILON);
284            }
285            _ => panic!("expected Exponential"),
286        }
287    }
288
289    #[test]
290    fn test_jitter_clamped() {
291        let retry = Retry::exponential(1).jitter(-0.5).build();
292        match retry.backoff {
293            Backoff::Exponential { jitter, .. } => assert_eq!(jitter, 0.0),
294            _ => panic!("expected Exponential"),
295        }
296
297        let retry = Retry::exponential(1).jitter(2.0).build();
298        match retry.backoff {
299            Backoff::Exponential { jitter, .. } => assert_eq!(jitter, 1.0),
300            _ => panic!("expected Exponential"),
301        }
302    }
303
304    #[test]
305    fn test_compute_delay_none() {
306        let retry = Retry::none();
307        assert_eq!(retry.compute_delay(0), Duration::ZERO);
308        assert_eq!(retry.compute_delay(5), Duration::ZERO);
309    }
310
311    #[test]
312    fn test_compute_delay_default() {
313        let retry = Retry::default();
314        assert_eq!(retry.compute_delay(0), Duration::from_secs(1));
315        assert_eq!(retry.compute_delay(5), Duration::from_secs(1));
316    }
317
318    #[test]
319    fn test_compute_delay_fixed() {
320        let retry = Retry::fixed(3, Duration::from_millis(100));
321        assert_eq!(retry.compute_delay(0), Duration::from_millis(100));
322        assert_eq!(retry.compute_delay(1), Duration::from_millis(100));
323        assert_eq!(retry.compute_delay(10), Duration::from_millis(100));
324    }
325
326    #[test]
327    fn test_compute_delay_exponential_no_jitter() {
328        let retry = Retry::exponential(5)
329            .initial_delay(Duration::from_millis(100))
330            .max_delay(Duration::from_secs(10))
331            .jitter(0.0)
332            .build();
333
334        assert_eq!(retry.compute_delay(0), Duration::from_millis(100));
335        assert_eq!(retry.compute_delay(1), Duration::from_millis(200));
336        assert_eq!(retry.compute_delay(2), Duration::from_millis(400));
337        assert_eq!(retry.compute_delay(3), Duration::from_millis(800));
338    }
339
340    #[test]
341    fn test_compute_delay_exponential_capped() {
342        let retry = Retry::exponential(10)
343            .initial_delay(Duration::from_millis(100))
344            .max_delay(Duration::from_millis(500))
345            .jitter(0.0)
346            .build();
347
348        assert_eq!(retry.compute_delay(0), Duration::from_millis(100));
349        assert_eq!(retry.compute_delay(1), Duration::from_millis(200));
350        assert_eq!(retry.compute_delay(2), Duration::from_millis(400));
351        // Should be capped at 500ms
352        assert_eq!(retry.compute_delay(3), Duration::from_millis(500));
353        assert_eq!(retry.compute_delay(10), Duration::from_millis(500));
354    }
355
356    #[test]
357    fn test_compute_delay_exponential_with_jitter() {
358        let retry = Retry::exponential(3)
359            .initial_delay(Duration::from_millis(100))
360            .max_delay(Duration::from_secs(1))
361            .jitter(0.25)
362            .build();
363
364        // With jitter, delay should be in [75ms, 125ms] for attempt 0
365        // Run multiple times to verify it's in range
366        for _ in 0..10 {
367            let delay = retry.compute_delay(0);
368            let millis = delay.as_millis();
369            assert!((75..=125).contains(&millis), "delay was {}ms", millis);
370        }
371    }
372
373    #[test]
374    fn test_jitter_factor_range() {
375        // Test that jitter_factor produces values in expected range
376        for _ in 0..100 {
377            let factor = jitter_factor(0.5);
378            assert!((0.5..=1.5).contains(&factor), "factor was {}", factor);
379        }
380    }
381
382    #[test]
383    fn test_jitter_factor_zero() {
384        assert_eq!(jitter_factor(0.0), 1.0);
385        assert_eq!(jitter_factor(-0.1), 1.0);
386    }
387
388    #[test]
389    fn test_from_builder() {
390        let builder = Retry::exponential(2).initial_delay(Duration::from_millis(50));
391        let retry: Retry = builder.into();
392        assert_eq!(retry.max_attempts, 2);
393    }
394}