Skip to main content

wickra_exchange_core/
retry.rs

1//! Exponential backoff policy for transient failures.
2//!
3//! Transient failures (5xx, network blips, rate limits) are worth retrying with
4//! an exponentially growing, capped delay and jitter to avoid thundering herds.
5//! [`Backoff`] is the *policy* — it computes delays and the retry decision; the
6//! actual sleep-and-retry loop lives in the real transport adapter and uses this
7//! policy. Jitter takes a `[0, 1)` fraction as an argument so the policy stays
8//! pure and testable (no hidden RNG).
9
10/// An exponential-backoff policy.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct Backoff {
13    base_ms: u64,
14    max_ms: u64,
15    max_retries: u32,
16}
17
18impl Backoff {
19    /// A policy starting at `base_ms`, doubling each attempt, capped at `max_ms`,
20    /// giving up after `max_retries`.
21    ///
22    /// `const` so a caller can state its policy as a constant next to the
23    /// reasoning for it, rather than rebuilding one per call.
24    #[must_use]
25    pub const fn new(base_ms: u64, max_ms: u64, max_retries: u32) -> Self {
26        Self {
27            base_ms,
28            max_ms,
29            max_retries,
30        }
31    }
32
33    /// The (un-jittered) delay before retry `attempt` (0-based): `base * 2^attempt`,
34    /// saturating and capped at `max_ms`.
35    #[must_use]
36    pub fn delay_ms(&self, attempt: u32) -> u64 {
37        let factor = 2u64.saturating_pow(attempt);
38        self.base_ms.saturating_mul(factor).min(self.max_ms)
39    }
40
41    /// The full-jitter delay for `attempt`: a uniform sample in `[0, delay]`,
42    /// given a random fraction `rand01` in `[0, 1)`.
43    #[must_use]
44    pub fn jittered_delay_ms(&self, attempt: u32, rand01: f64) -> u64 {
45        let clamped = rand01.clamp(0.0, 1.0);
46        #[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
47        let jittered = self.delay_ms(attempt) as f64 * clamped;
48        jittered as u64
49    }
50
51    /// Whether an `attempt` (0-based) should be retried.
52    #[must_use]
53    pub fn should_retry(&self, attempt: u32) -> bool {
54        attempt < self.max_retries
55    }
56
57    /// The configured maximum number of retries.
58    #[must_use]
59    pub fn max_retries(&self) -> u32 {
60        self.max_retries
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    #[test]
69    fn delay_doubles_and_caps() {
70        let b = Backoff::new(100, 2_000, 5);
71        assert_eq!(b.delay_ms(0), 100);
72        assert_eq!(b.delay_ms(1), 200);
73        assert_eq!(b.delay_ms(2), 400);
74        assert_eq!(b.delay_ms(3), 800);
75        assert_eq!(b.delay_ms(4), 1_600);
76        // Capped.
77        assert_eq!(b.delay_ms(5), 2_000);
78        assert_eq!(b.delay_ms(60), 2_000);
79    }
80
81    #[test]
82    fn jitter_scales_within_the_delay() {
83        let b = Backoff::new(100, 10_000, 5);
84        assert_eq!(b.jittered_delay_ms(2, 0.0), 0);
85        assert_eq!(b.jittered_delay_ms(2, 0.5), 200); // 400 * 0.5
86        assert_eq!(b.jittered_delay_ms(2, 1.0), 400);
87        // Out-of-range fractions are clamped.
88        assert_eq!(b.jittered_delay_ms(2, -1.0), 0);
89        assert_eq!(b.jittered_delay_ms(2, 2.0), 400);
90    }
91
92    #[test]
93    fn retry_decision_respects_the_limit() {
94        let b = Backoff::new(100, 1_000, 3);
95        assert_eq!(b.max_retries(), 3);
96        assert!(b.should_retry(0));
97        assert!(b.should_retry(2));
98        assert!(!b.should_retry(3));
99        assert!(!b.should_retry(10));
100    }
101}