Skip to main content

laminar_connectors/retry/
mod.rs

1//! Retry/backoff helper shared by connectors.
2//!
3//! Centralises exponential backoff with jitter so every reconnect path
4//! behaves the same way: capped exponent (no shift overflow), capped
5//! delay, jittered to break thundering-herd on broker-wide outages.
6
7use std::time::Duration;
8
9use rand::RngExt;
10
11/// Exponential backoff schedule with jitter and a cap.
12#[derive(Debug, Clone, Copy)]
13pub struct Backoff {
14    initial: Duration,
15    max: Duration,
16    /// Multiplicative jitter range, expressed as a fraction in `[0.0, 1.0]`.
17    /// `0.25` means the actual delay is uniform on
18    /// `[delay * 0.75, delay * 1.25]`.
19    jitter: f64,
20}
21
22impl Backoff {
23    /// New backoff with the given bounds and jitter fraction.
24    #[must_use]
25    pub const fn new(initial: Duration, max: Duration, jitter: f64) -> Self {
26        Self {
27            initial,
28            max,
29            jitter,
30        }
31    }
32
33    /// Default broker-reconnect schedule: 1s → 30s, ±25 % jitter.
34    /// Jitter prevents reconnect storms during simultaneous endpoint restarts.
35    #[must_use]
36    pub const fn broker_reconnect() -> Self {
37        Self::new(Duration::from_secs(1), Duration::from_secs(30), 0.25)
38    }
39
40    /// Compute the delay for `attempt` (0-indexed). Caps the exponent at
41    /// 30 to prevent shift overflow with adversarial inputs, then caps
42    /// the resulting duration at `self.max`, then applies jitter.
43    #[must_use]
44    pub fn delay(&self, attempt: u32) -> Duration {
45        // 2^30 seconds is ~34 years; capping the exponent at 30 keeps
46        // the multiplication in u64 range for any sane `initial`.
47        let shift = attempt.min(30);
48        let factor = 1u64 << shift;
49        let raw_nanos = self
50            .initial
51            .as_nanos()
52            .saturating_mul(u128::from(factor))
53            .min(u128::from(u64::MAX));
54        #[allow(clippy::cast_possible_truncation)]
55        let raw = Duration::from_nanos(raw_nanos as u64).min(self.max);
56
57        if self.jitter <= 0.0 {
58            return raw;
59        }
60        let mut rng = rand::rng();
61        let frac: f64 = rng.random_range(-self.jitter..=self.jitter);
62        #[allow(
63            clippy::cast_precision_loss,
64            clippy::cast_sign_loss,
65            clippy::cast_possible_truncation
66        )]
67        let jittered_nanos = (raw.as_nanos() as f64 * (1.0 + frac)).max(0.0) as u64;
68        Duration::from_nanos(jittered_nanos).min(self.max)
69    }
70}
71
72#[cfg(test)]
73mod tests;