aster_forge_utils/
backoff.rs

1//! Shared exponential backoff calculations.
2//!
3//! This module owns only mechanical delay arithmetic. Callers retain the
4//! policy for retryability, cancellation, reset conditions, and observability.
5
6use std::time::Duration;
7
8/// Calculates an uncapped exponential delay for a zero-based retry index.
9#[must_use]
10pub fn exponential_delay(initial_delay: Duration, retry_index: u32) -> Duration {
11    let mut delay = initial_delay;
12    let mut remaining = retry_index.min(127);
13    while remaining > 0 && delay < Duration::MAX {
14        delay = delay.saturating_add(delay);
15        remaining -= 1;
16    }
17    delay
18}
19
20/// Applies a hard upper bound to a delay.
21#[must_use]
22pub fn cap_delay(delay: Duration, max_delay: Duration) -> Duration {
23    delay.min(max_delay)
24}
25
26/// Applies an explicit percentage to a delay, saturating at `Duration::MAX`.
27#[must_use]
28pub fn apply_jitter(delay: Duration, percent: u16) -> Duration {
29    let nanos = delay.as_nanos().saturating_mul(u128::from(percent));
30    let nanos = nanos.checked_div(100).unwrap_or(0);
31    duration_from_nanos(nanos)
32}
33
34/// Applies a sampled percentage using the default thread-local random generator.
35///
36/// The bounds are inclusive and clamped to a practical percentage range. Callers that need
37/// deterministic behavior should use [`apply_jitter`] instead.
38#[must_use]
39pub fn randomized_jitter(delay: Duration, min_percent: u16, max_percent: u16) -> Duration {
40    use rand::RngExt;
41
42    let min_percent = min_percent.min(1000);
43    let max_percent = max_percent.min(1000).max(min_percent);
44    apply_jitter(delay, rand::rng().random_range(min_percent..=max_percent))
45}
46
47fn duration_from_nanos(nanos: u128) -> Duration {
48    let max_nanos = Duration::MAX.as_nanos();
49    let nanos = nanos.min(max_nanos);
50    let seconds = nanos / 1_000_000_000;
51    let subsec_nanos = u32::try_from(nanos % 1_000_000_000).unwrap_or(999_999_999);
52    Duration::new(u64::try_from(seconds).unwrap_or(u64::MAX), subsec_nanos)
53}
54
55#[cfg(test)]
56mod tests {
57    use super::{apply_jitter, cap_delay, exponential_delay};
58    use std::time::Duration;
59
60    #[test]
61    fn exponential_delay_doubles_and_saturates() {
62        let initial = Duration::from_millis(100);
63        assert_eq!(exponential_delay(initial, 0), Duration::from_millis(100));
64        assert_eq!(exponential_delay(initial, 1), Duration::from_millis(200));
65        assert_eq!(exponential_delay(initial, 2), Duration::from_millis(400));
66        assert_eq!(exponential_delay(initial, u32::MAX), Duration::MAX);
67    }
68
69    #[test]
70    fn exponential_delay_handles_zero_and_duration_max() {
71        assert_eq!(exponential_delay(Duration::ZERO, u32::MAX), Duration::ZERO);
72        assert_eq!(exponential_delay(Duration::MAX, 0), Duration::MAX);
73        assert_eq!(exponential_delay(Duration::MAX, 1), Duration::MAX);
74    }
75
76    #[test]
77    fn cap_delay_handles_zero_equal_and_inverted_bounds() {
78        assert_eq!(
79            cap_delay(Duration::from_secs(1), Duration::ZERO),
80            Duration::ZERO
81        );
82        assert_eq!(
83            cap_delay(Duration::from_secs(1), Duration::from_secs(1)),
84            Duration::from_secs(1)
85        );
86        assert_eq!(
87            cap_delay(Duration::from_secs(1), Duration::from_secs(2)),
88            Duration::from_secs(1)
89        );
90    }
91
92    #[test]
93    fn apply_jitter_supports_exact_boundaries_and_saturates() {
94        let delay = Duration::from_millis(100);
95        assert_eq!(apply_jitter(delay, 0), Duration::ZERO);
96        assert_eq!(apply_jitter(delay, 50), Duration::from_millis(50));
97        assert_eq!(apply_jitter(delay, 100), delay);
98        assert_eq!(apply_jitter(delay, 150), Duration::from_millis(150));
99        assert_eq!(apply_jitter(Duration::MAX, u16::MAX), Duration::MAX);
100    }
101
102    #[test]
103    fn composition_keeps_cap_order_explicit() {
104        let raw = exponential_delay(Duration::from_millis(100), 2);
105        assert_eq!(
106            cap_delay(apply_jitter(raw, 150), Duration::from_millis(250)),
107            Duration::from_millis(250)
108        );
109        assert_eq!(
110            apply_jitter(cap_delay(raw, Duration::from_millis(250)), 150),
111            Duration::from_millis(375)
112        );
113    }
114
115    #[test]
116    fn randomized_jitter_stays_inside_configured_range() {
117        for _ in 0..64 {
118            let delay = super::randomized_jitter(Duration::from_millis(100), 50, 100);
119            assert!((50..=100).contains(&delay.as_millis()));
120        }
121    }
122
123    #[test]
124    fn randomized_jitter_normalizes_degenerate_and_large_bounds() {
125        assert_eq!(
126            super::randomized_jitter(Duration::from_millis(100), 200, 50),
127            Duration::from_millis(200)
128        );
129        assert_eq!(
130            super::randomized_jitter(Duration::from_millis(100), u16::MAX, u16::MAX),
131            Duration::from_secs(1)
132        );
133        assert_eq!(
134            super::randomized_jitter(Duration::ZERO, 50, 150),
135            Duration::ZERO
136        );
137    }
138}