Skip to main content

vgi_rpc/
retry.rs

1//! Retry helpers.
2//!
3//! Exponential-backoff + jitter schedule matching the Python / Go
4//! reference clients. The crate ships server-only today; these helpers
5//! are exposed for symmetry so a future client crate (or user code
6//! talking to remote services) reuses the same semantics without
7//! redefining the policy.
8
9use std::time::Duration;
10
11/// Configuration for a retry schedule.
12///
13/// Retries are disabled by default because an RPC may have side effects and a
14/// connection failure does not reveal whether the server applied the call.
15/// Set [`max_attempts`](Self::max_attempts) above `1` to opt into replaying
16/// eligible HTTP requests.
17#[derive(Clone, Debug)]
18pub struct RetryConfig {
19    /// Maximum number of attempts (including the first one). `1` disables retries.
20    pub max_attempts: u32,
21    /// Base delay for the first retry.
22    pub base_delay: Duration,
23    /// Maximum delay between attempts; the exponential curve caps here.
24    pub max_delay: Duration,
25    /// Multiplier applied to the delay each attempt (typically `2.0`).
26    pub multiplier: f64,
27    /// Random jitter fraction applied to each computed delay, in `[0, 1]`.
28    /// `0.0` disables jitter.
29    pub jitter: f64,
30}
31
32impl Default for RetryConfig {
33    fn default() -> Self {
34        Self {
35            max_attempts: 1,
36            base_delay: Duration::from_millis(100),
37            max_delay: Duration::from_secs(10),
38            multiplier: 2.0,
39            jitter: 0.2,
40        }
41    }
42}
43
44impl RetryConfig {
45    /// Convenience: `max_attempts=1` — no retries.
46    pub fn disabled() -> Self {
47        Self {
48            max_attempts: 1,
49            ..Default::default()
50        }
51    }
52
53    /// Compute the sleep before attempt `n` (0-indexed).
54    /// `n == 0` → caller is about to make the first attempt, no delay.
55    /// `n == 1` → delay before the first retry, and so on.
56    ///
57    /// Jitter is drawn from a real per-call entropy source, so callers
58    /// retrying in lockstep do **not** compute identical delays — that
59    /// decorrelation is the entire point of jitter (it prevents a
60    /// synchronized retry storm against a recovering server). For
61    /// reproducible delays in tests, use [`delay_before_with_jitter`].
62    ///
63    /// [`delay_before_with_jitter`]: Self::delay_before_with_jitter
64    pub fn delay_before(&self, attempt: u32) -> Duration {
65        self.delay_before_with_jitter(attempt, jitter_fraction())
66    }
67
68    /// Like [`delay_before`](Self::delay_before) but with the jitter
69    /// fraction supplied explicitly (in `[0, 1)`). Deterministic — used
70    /// by tests, or by callers that want to plug their own RNG.
71    pub fn delay_before_with_jitter(&self, attempt: u32, jitter_frac: f64) -> Duration {
72        if attempt == 0 {
73            return Duration::ZERO;
74        }
75        let exp = (attempt - 1) as i32;
76        let base = self.base_delay.as_secs_f64() * self.multiplier.powi(exp);
77        let mut d = base.min(self.max_delay.as_secs_f64());
78        if self.jitter > 0.0 {
79            let spread = d * self.jitter;
80            d += spread * (jitter_frac.clamp(0.0, 1.0) * 2.0 - 1.0);
81        }
82        // Guard against a non-finite result (e.g. a NaN `multiplier`)
83        // before `from_secs_f64`, which would otherwise panic.
84        if !d.is_finite() {
85            d = self.max_delay.as_secs_f64();
86        }
87        Duration::from_secs_f64(d.max(0.0))
88    }
89
90    /// Iterator over per-attempt delays (`attempt = 0..max_attempts`).
91    pub fn schedule(&self) -> impl Iterator<Item = Duration> + '_ {
92        (0..self.max_attempts).map(move |n| self.delay_before(n))
93    }
94}
95
96/// A jitter fraction in `[0, 1)` drawn from a real per-call entropy
97/// source: the wall clock's sub-second component mixed with a
98/// thread-local sequence counter, run through splitmix64. Not
99/// cryptographic — jitter does not need to be — but it does give every
100/// caller a distinct value, which a fixed hash of the attempt number
101/// (the previous implementation) did not.
102fn jitter_fraction() -> f64 {
103    use std::cell::Cell;
104    use std::time::{SystemTime, UNIX_EPOCH};
105
106    thread_local! {
107        static SEQ: Cell<u64> = const { Cell::new(0) };
108    }
109    let seq = SEQ.with(|c| {
110        let v = c.get().wrapping_add(1);
111        c.set(v);
112        v
113    });
114    let nanos = SystemTime::now()
115        .duration_since(UNIX_EPOCH)
116        .map(|d| d.as_nanos() as u64)
117        .unwrap_or(0);
118
119    // splitmix64 over the combined entropy.
120    let mut x = nanos
121        .wrapping_mul(0x9E37_79B9_7F4A_7C15)
122        .wrapping_add(seq.wrapping_mul(0xD1B5_4A32_D192_ED03));
123    x ^= x >> 30;
124    x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9);
125    x ^= x >> 27;
126    x = x.wrapping_mul(0x94D0_49BB_1331_11EB);
127    x ^= x >> 31;
128    (x as f64) / (u64::MAX as f64)
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn first_attempt_has_no_delay() {
137        let cfg = RetryConfig::default();
138        assert_eq!(cfg.delay_before(0), Duration::ZERO);
139    }
140
141    #[test]
142    fn exponential_growth_capped_at_max() {
143        let cfg = RetryConfig {
144            max_attempts: 6,
145            base_delay: Duration::from_millis(100),
146            max_delay: Duration::from_millis(400),
147            multiplier: 2.0,
148            jitter: 0.0,
149        };
150        let delays: Vec<Duration> = cfg.schedule().collect();
151        assert_eq!(delays[0], Duration::ZERO);
152        assert_eq!(delays[1], Duration::from_millis(100));
153        assert_eq!(delays[2], Duration::from_millis(200));
154        assert_eq!(delays[3], Duration::from_millis(400)); // capped
155        assert_eq!(delays[4], Duration::from_millis(400));
156    }
157
158    #[test]
159    fn disabled_yields_single_zero_delay() {
160        let cfg = RetryConfig::disabled();
161        let delays: Vec<Duration> = cfg.schedule().collect();
162        assert_eq!(delays, vec![Duration::ZERO]);
163    }
164
165    #[test]
166    fn retries_are_opt_in() {
167        assert_eq!(RetryConfig::default().max_attempts, 1);
168    }
169
170    #[test]
171    fn jitter_stays_non_negative() {
172        let cfg = RetryConfig {
173            max_attempts: 10,
174            base_delay: Duration::from_millis(1),
175            max_delay: Duration::from_secs(1),
176            multiplier: 2.0,
177            jitter: 0.9,
178        };
179        for d in cfg.schedule() {
180            assert!(d >= Duration::ZERO);
181        }
182    }
183
184    #[test]
185    fn jitter_is_not_deterministic_across_calls() {
186        // The whole point of jitter: two callers (or the same caller
187        // twice) must not compute identical delays for the same attempt.
188        let cfg = RetryConfig {
189            max_attempts: 2,
190            base_delay: Duration::from_millis(100),
191            max_delay: Duration::from_secs(10),
192            multiplier: 2.0,
193            jitter: 0.5,
194        };
195        let mut seen = std::collections::HashSet::new();
196        for _ in 0..50 {
197            seen.insert(cfg.delay_before(1).as_nanos());
198        }
199        assert!(
200            seen.len() > 1,
201            "jitter produced identical delays on every call"
202        );
203    }
204
205    #[test]
206    fn explicit_jitter_fraction_is_reproducible() {
207        let cfg = RetryConfig {
208            max_attempts: 2,
209            base_delay: Duration::from_millis(100),
210            max_delay: Duration::from_secs(10),
211            multiplier: 2.0,
212            jitter: 0.5,
213        };
214        let a = cfg.delay_before_with_jitter(1, 0.25);
215        let b = cfg.delay_before_with_jitter(1, 0.25);
216        assert_eq!(a, b);
217        // A different fraction yields a different delay.
218        assert_ne!(a, cfg.delay_before_with_jitter(1, 0.75));
219    }
220
221    #[test]
222    fn non_finite_multiplier_does_not_panic() {
223        let cfg = RetryConfig {
224            max_attempts: 3,
225            base_delay: Duration::from_millis(100),
226            max_delay: Duration::from_secs(10),
227            multiplier: f64::NAN,
228            jitter: 0.0,
229        };
230        // Must clamp to a finite delay rather than panicking in
231        // `Duration::from_secs_f64`.
232        let _ = cfg.delay_before_with_jitter(2, 0.0);
233    }
234}