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    ///
45    /// This is the default for backward compatibility. Consider using
46    /// [`Backoff::Exponential`] for production use to avoid overwhelming
47    /// agents under load.
48    #[default]
49    None,
50
51    /// Fixed delay between each retry attempt.
52    Fixed {
53        /// Delay before each retry
54        delay: Duration,
55    },
56
57    /// Exponential backoff: delay doubles after each attempt.
58    ///
59    /// With jitter enabled (recommended), the actual delay is randomized
60    /// within a range to prevent synchronized retries from multiple clients.
61    Exponential {
62        /// Initial delay before first retry
63        initial: Duration,
64        /// Maximum delay cap
65        max: Duration,
66        /// Jitter factor (0.0-1.0). E.g., 0.25 means ±25% randomization.
67        jitter: f64,
68    },
69}
70
71impl Default for Retry {
72    /// Default: 3 retries with no delay between attempts.
73    fn default() -> Self {
74        Self {
75            max_attempts: 3,
76            backoff: Backoff::None,
77        }
78    }
79}
80
81impl Retry {
82    /// No retries - request is sent once and fails on timeout.
83    pub fn none() -> Self {
84        Self {
85            max_attempts: 0,
86            backoff: Backoff::None,
87        }
88    }
89
90    /// Fixed delay between retries.
91    ///
92    /// # Arguments
93    ///
94    /// * `attempts` - Maximum number of retry attempts
95    /// * `delay` - Fixed delay before each retry
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    pub fn exponential(attempts: u32) -> RetryBuilder {
123        RetryBuilder {
124            max_attempts: attempts,
125            ..Default::default()
126        }
127    }
128
129    /// Compute the delay before the next retry attempt.
130    ///
131    /// Returns `Duration::ZERO` for `Backoff::None`.
132    pub fn compute_delay(&self, attempt: u32) -> Duration {
133        match &self.backoff {
134            Backoff::None => Duration::ZERO,
135            Backoff::Fixed { delay } => *delay,
136            Backoff::Exponential {
137                initial,
138                max,
139                jitter,
140            } => {
141                // Exponential: initial * 2^attempt, capped at max
142                // Clamp attempt to prevent overflow (32 is more than enough)
143                let shift = attempt.min(31);
144                let multiplier = 1u32.checked_shl(shift).unwrap_or(u32::MAX);
145                let base = initial.saturating_mul(multiplier);
146                let capped = base.min(*max);
147
148                // Apply jitter
149                let factor = jitter_factor(*jitter);
150                Duration::from_secs_f64(capped.as_secs_f64() * factor)
151            }
152        }
153    }
154}
155
156/// Builder for exponential backoff retry configuration.
157pub struct RetryBuilder {
158    max_attempts: u32,
159    initial: Duration,
160    max: Duration,
161    jitter: f64,
162}
163
164impl Default for RetryBuilder {
165    fn default() -> Self {
166        Self {
167            max_attempts: 3,
168            initial: Duration::from_secs(1),
169            max: Duration::from_secs(5),
170            jitter: 0.25,
171        }
172    }
173}
174
175impl RetryBuilder {
176    /// Set the initial delay before the first retry (default: 1 second).
177    pub fn initial_delay(mut self, delay: Duration) -> Self {
178        self.initial = delay;
179        self
180    }
181
182    /// Set the maximum delay cap (default: 5 seconds).
183    pub fn max_delay(mut self, delay: Duration) -> Self {
184        self.max = delay;
185        self
186    }
187
188    /// Set the jitter factor (default: 0.25, meaning ±25% randomization).
189    ///
190    /// Jitter helps prevent synchronized retries when multiple clients
191    /// experience timeouts simultaneously.
192    ///
193    /// The value is clamped to [0.0, 1.0].
194    pub fn jitter(mut self, jitter: f64) -> Self {
195        self.jitter = jitter.clamp(0.0, 1.0);
196        self
197    }
198
199    /// Build the [`Retry`] configuration.
200    pub fn build(self) -> Retry {
201        Retry {
202            max_attempts: self.max_attempts,
203            backoff: Backoff::Exponential {
204                initial: self.initial,
205                max: self.max,
206                jitter: self.jitter,
207            },
208        }
209    }
210}
211
212impl From<RetryBuilder> for Retry {
213    fn from(builder: RetryBuilder) -> Self {
214        builder.build()
215    }
216}
217
218/// Global counter for jitter generation.
219static JITTER_COUNTER: AtomicU64 = AtomicU64::new(0);
220
221/// Compute a jitter factor in the range [1-jitter, 1+jitter].
222///
223/// Uses a multiplicative hash of an atomic counter to generate pseudo-random
224/// values. This is sufficient for retry desynchronization without requiring
225/// true randomness.
226fn jitter_factor(jitter: f64) -> f64 {
227    if jitter <= 0.0 {
228        return 1.0;
229    }
230    // Multiplicative hash of counter (Knuth's method)
231    let counter = JITTER_COUNTER.fetch_add(1, Ordering::Relaxed);
232    let hash = counter.wrapping_mul(0x5851f42d4c957f2d);
233    // Convert to [0, 1) range using upper bits (better distribution)
234    let random = (hash >> 11) as f64 / ((1u64 << 53) as f64);
235    // Return factor in [1-jitter, 1+jitter]
236    1.0 + (random - 0.5) * 2.0 * jitter
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_retry_none() {
245        let retry = Retry::none();
246        assert_eq!(retry.max_attempts, 0);
247        assert!(matches!(retry.backoff, Backoff::None));
248    }
249
250    #[test]
251    fn test_retry_default() {
252        let retry = Retry::default();
253        assert_eq!(retry.max_attempts, 3);
254        assert!(matches!(retry.backoff, Backoff::None));
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::default();
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_fixed() {
313        let retry = Retry::fixed(3, Duration::from_millis(100));
314        assert_eq!(retry.compute_delay(0), Duration::from_millis(100));
315        assert_eq!(retry.compute_delay(1), Duration::from_millis(100));
316        assert_eq!(retry.compute_delay(10), Duration::from_millis(100));
317    }
318
319    #[test]
320    fn test_compute_delay_exponential_no_jitter() {
321        let retry = Retry::exponential(5)
322            .initial_delay(Duration::from_millis(100))
323            .max_delay(Duration::from_secs(10))
324            .jitter(0.0)
325            .build();
326
327        assert_eq!(retry.compute_delay(0), Duration::from_millis(100));
328        assert_eq!(retry.compute_delay(1), Duration::from_millis(200));
329        assert_eq!(retry.compute_delay(2), Duration::from_millis(400));
330        assert_eq!(retry.compute_delay(3), Duration::from_millis(800));
331    }
332
333    #[test]
334    fn test_compute_delay_exponential_capped() {
335        let retry = Retry::exponential(10)
336            .initial_delay(Duration::from_millis(100))
337            .max_delay(Duration::from_millis(500))
338            .jitter(0.0)
339            .build();
340
341        assert_eq!(retry.compute_delay(0), Duration::from_millis(100));
342        assert_eq!(retry.compute_delay(1), Duration::from_millis(200));
343        assert_eq!(retry.compute_delay(2), Duration::from_millis(400));
344        // Should be capped at 500ms
345        assert_eq!(retry.compute_delay(3), Duration::from_millis(500));
346        assert_eq!(retry.compute_delay(10), Duration::from_millis(500));
347    }
348
349    #[test]
350    fn test_compute_delay_exponential_with_jitter() {
351        let retry = Retry::exponential(3)
352            .initial_delay(Duration::from_millis(100))
353            .max_delay(Duration::from_secs(1))
354            .jitter(0.25)
355            .build();
356
357        // With jitter, delay should be in [75ms, 125ms] for attempt 0
358        // Run multiple times to verify it's in range
359        for _ in 0..10 {
360            let delay = retry.compute_delay(0);
361            let millis = delay.as_millis();
362            assert!((75..=125).contains(&millis), "delay was {}ms", millis);
363        }
364    }
365
366    #[test]
367    fn test_jitter_factor_range() {
368        // Test that jitter_factor produces values in expected range
369        for _ in 0..100 {
370            let factor = jitter_factor(0.5);
371            assert!((0.5..=1.5).contains(&factor), "factor was {}", factor);
372        }
373    }
374
375    #[test]
376    fn test_jitter_factor_zero() {
377        assert_eq!(jitter_factor(0.0), 1.0);
378        assert_eq!(jitter_factor(-0.1), 1.0);
379    }
380
381    #[test]
382    fn test_from_builder() {
383        let builder = Retry::exponential(2).initial_delay(Duration::from_millis(50));
384        let retry: Retry = builder.into();
385        assert_eq!(retry.max_attempts, 2);
386    }
387}