Skip to main content

rskit_util/
backoff.rs

1//! Stateless mathematical backoff calculations.
2
3use std::time::Duration;
4
5/// Calculate a capped exponential backoff delay.
6///
7/// Completely stateless and does not invoke thread sleep or tokio timers.
8///
9/// # Examples
10///
11/// ```
12/// use std::time::Duration;
13/// use rskit_util::backoff::calculate_backoff;
14///
15/// let min = Duration::from_millis(100);
16/// let max = Duration::from_secs(10);
17///
18/// // First attempt:
19/// let backoff_1 = calculate_backoff(1, min, max);
20/// assert!(backoff_1 >= min);
21/// ```
22pub fn calculate_backoff(attempt: u32, min_delay: Duration, max_delay: Duration) -> Duration {
23    if max_delay <= min_delay {
24        return min_delay;
25    }
26    if attempt <= 1 {
27        return min_delay;
28    }
29    if min_delay.is_zero() {
30        return min_delay;
31    }
32
33    let mut delay = min_delay;
34    for _ in 1..attempt {
35        let Some(next) = delay.checked_mul(2) else {
36            return max_delay;
37        };
38        if next >= max_delay {
39            return max_delay;
40        }
41        delay = next;
42    }
43    delay
44}
45
46/// Calculate a deterministic jittered delay inside `[min_delay, capped_backoff]`.
47///
48/// `jitter_permyriad` is an integer ratio in the inclusive range `0..=10_000`,
49/// where `0` returns `min_delay` and `10_000` returns the capped exponential delay.
50#[must_use]
51pub fn calculate_jittered_backoff(
52    attempt: u32,
53    min_delay: Duration,
54    max_delay: Duration,
55    jitter_permyriad: u16,
56) -> Option<Duration> {
57    if jitter_permyriad > 10_000 {
58        return None;
59    }
60    let limit = calculate_backoff(attempt, min_delay, max_delay);
61    if limit <= min_delay {
62        return Some(min_delay);
63    }
64
65    let min_nanos = min_delay.as_nanos();
66    let span_nanos = limit.as_nanos().saturating_sub(min_nanos);
67    let jitter_nanos = span_nanos
68        .checked_mul(u128::from(jitter_permyriad))?
69        .checked_div(10_000)?;
70    min_delay.checked_add(duration_from_nanos(jitter_nanos)?)
71}
72
73fn duration_from_nanos(nanos: u128) -> Option<Duration> {
74    let secs = nanos / 1_000_000_000;
75    let subsec_nanos = nanos % 1_000_000_000;
76    Some(Duration::new(
77        u64::try_from(secs).ok()?,
78        u32::try_from(subsec_nanos).ok()?,
79    ))
80}
81
82/// Reusable exponential backoff calculator configuration.
83#[derive(Debug, Clone, Copy)]
84pub struct ExponentialBackoff {
85    min_delay: Duration,
86    max_delay: Duration,
87}
88
89impl ExponentialBackoff {
90    /// Create a new backoff calculator config.
91    #[must_use]
92    pub const fn new(min_delay: Duration, max_delay: Duration) -> Self {
93        Self {
94            min_delay,
95            max_delay,
96        }
97    }
98
99    /// Calculate the delay for a given attempt index (starting at 1).
100    #[must_use]
101    pub fn delay(&self, attempt: u32) -> Duration {
102        calculate_backoff(attempt, self.min_delay, self.max_delay)
103    }
104
105    /// Calculate a jittered delay for a given attempt using an injected factor.
106    #[must_use]
107    pub fn jittered_delay(&self, attempt: u32, jitter_permyriad: u16) -> Option<Duration> {
108        calculate_jittered_backoff(attempt, self.min_delay, self.max_delay, jitter_permyriad)
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn test_calculate_backoff_bounds() {
118        let min = Duration::from_millis(100);
119        let max = Duration::from_secs(5);
120
121        for attempt in 1..10 {
122            let backoff = calculate_backoff(attempt, min, max);
123            assert!(backoff >= min, "delay {backoff:?} < min {min:?}");
124            assert!(backoff <= max, "delay {backoff:?} > max {max:?}");
125        }
126    }
127
128    #[test]
129    fn test_calculate_backoff_reaches_cap_for_high_attempts() {
130        let min = Duration::from_nanos(1);
131        let max = Duration::from_secs(10);
132
133        assert_eq!(calculate_backoff(64, min, max), max);
134    }
135
136    #[test]
137    fn test_calculate_jittered_backoff_is_deterministic() {
138        let min = Duration::from_millis(100);
139        let max = Duration::from_secs(5);
140
141        assert_eq!(calculate_jittered_backoff(3, min, max, 0), Some(min));
142        assert_eq!(
143            calculate_jittered_backoff(3, min, max, 10_000),
144            Some(calculate_backoff(3, min, max))
145        );
146        assert_eq!(calculate_jittered_backoff(3, min, max, 10_001), None);
147    }
148
149    #[test]
150    fn test_exponential_backoff_struct() {
151        let backoff = ExponentialBackoff::new(Duration::from_millis(50), Duration::from_secs(1));
152        let delay_1 = backoff.delay(1);
153        assert!(delay_1 >= Duration::from_millis(50));
154        assert!(delay_1 <= Duration::from_secs(1));
155        assert!(backoff.jittered_delay(1, 5_000).is_some());
156    }
157}