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