Skip to main content

tuwunel_core/utils/time/
exponential_backoff.rs

1//! Retry-backoff calculations.
2//!
3//! The helpers determine whether a retry delay remains active and derive
4//! retry-streak caps from duration bounds. Delay calculations saturate at the
5//! configured maximum where applicable.
6
7use std::time::Duration;
8
9/// Returns false if the exponential backoff has expired based on the inputs
10#[inline]
11#[must_use]
12pub fn continue_exponential_backoff_secs(
13	min: u64,
14	max: u64,
15	elapsed: Duration,
16	tries: u32,
17) -> bool {
18	exponential_backoff_remaining_secs(min, max, elapsed, tries).is_some()
19}
20
21/// Returns false if the exponential backoff has expired based on the inputs
22#[inline]
23#[must_use]
24pub fn continue_exponential_backoff(
25	min: Duration,
26	max: Duration,
27	elapsed: Duration,
28	tries: u32,
29) -> bool {
30	exponential_backoff_remaining(min, max, elapsed, tries).is_some()
31}
32
33/// Calculates the remaining exponential-backoff hold from whole-second bounds.
34///
35/// The bounds are converted to durations before applying the quadratic retry
36/// curve. `None` means the hold has elapsed.
37#[inline]
38#[must_use]
39pub fn exponential_backoff_remaining_secs(
40	min: u64,
41	max: u64,
42	elapsed: Duration,
43	tries: u32,
44) -> Option<Duration> {
45	let min = Duration::from_secs(min);
46	let max = Duration::from_secs(max);
47
48	exponential_backoff_remaining(min, max, elapsed, tries)
49}
50
51/// Calculates the remaining exponential-backoff hold.
52///
53/// The window grows quadratically with the retry count and is capped at
54/// `max`. `None` means the hold has elapsed.
55#[inline]
56#[must_use]
57pub fn exponential_backoff_remaining(
58	min: Duration,
59	max: Duration,
60	elapsed: Duration,
61	tries: u32,
62) -> Option<Duration> {
63	let window = min
64		.saturating_mul(tries)
65		.saturating_mul(tries)
66		.min(max);
67
68	window
69		.checked_sub(elapsed)
70		.filter(|remaining| !remaining.is_zero())
71}
72
73/// Derives a retry-streak cap from the whole-second ratio of `max` to `min`.
74///
75/// Let `r = max.as_secs() / min.as_secs().max(1)` using integer division. The
76/// result is `ceil(sqrt(r))`, clamped to the range `1..=u32::MAX`. Subsecond
77/// components and the division remainder are discarded.
78#[inline]
79#[must_use]
80pub fn exponential_backoff_streak_cap(min: Duration, max: Duration) -> u32 {
81	let min_secs = min.as_secs().max(1);
82	let ratio = max.as_secs().checked_div(min_secs).unwrap_or(0);
83	let floor = ratio.isqrt();
84	let ceil = if floor.saturating_mul(floor) < ratio {
85		floor.saturating_add(1)
86	} else {
87		floor
88	};
89
90	u32::try_from(ceil).unwrap_or(u32::MAX).max(1)
91}
92
93#[cfg(test)]
94mod tests {
95	use std::time::Duration;
96
97	use super::{exponential_backoff_remaining, exponential_backoff_remaining_secs};
98
99	#[test]
100	fn remaining_tracks_the_quadratic_window() {
101		let min = Duration::from_secs(3);
102		let max = Duration::from_mins(1);
103		let elapsed = Duration::from_secs(5);
104
105		assert_eq!(
106			exponential_backoff_remaining(min, max, elapsed, 2),
107			Some(Duration::from_secs(7)),
108		);
109	}
110
111	#[test]
112	fn remaining_caps_and_expires_at_the_boundary() {
113		let min = Duration::from_secs(3);
114		let max = Duration::from_mins(1);
115		let elapsed = Duration::from_mins(1);
116
117		assert_eq!(
118			exponential_backoff_remaining(min, max, Duration::from_secs(15), 100),
119			Some(Duration::from_secs(45)),
120		);
121
122		assert_eq!(exponential_backoff_remaining(min, max, elapsed, 100), None);
123		assert_eq!(
124			exponential_backoff_remaining(Duration::ZERO, max, Duration::ZERO, u32::MAX),
125			None,
126		);
127		assert_eq!(
128			exponential_backoff_remaining(
129				Duration::MAX,
130				Duration::MAX,
131				Duration::from_secs(1),
132				u32::MAX,
133			),
134			Duration::MAX.checked_sub(Duration::from_secs(1)),
135		);
136	}
137
138	#[test]
139	fn seconds_wrapper_matches_duration_bounds() {
140		let elapsed = Duration::from_secs(5);
141
142		assert_eq!(
143			exponential_backoff_remaining_secs(3, 60, elapsed, 2),
144			exponential_backoff_remaining(
145				Duration::from_secs(3),
146				Duration::from_mins(1),
147				elapsed,
148				2,
149			),
150		);
151	}
152}