Skip to main content

taskvisor/policies/
backoff.rs

1//! # Backoff policy for retrying tasks.
2//!
3//! [`BackoffPolicy`] controls how retry delays grow after repeated failures.
4//! It is parameterized by:
5//! - [`BackoffPolicy::factor`] the multiplicative growth factor;
6//! - [`BackoffPolicy::first`] the initial delay;
7//! - [`BackoffPolicy::max`] the maximum delay cap.
8//!
9//! The delay for attempt `n` is computed as `first × factor^n`, clamped to `max`,
10//! then jitter is applied. Because the base delay is derived purely from the attempt
11//! number, jitter output never feeds back into subsequent calculations — this prevents
12//! the negative feedback loop that causes delays to shrink over time.
13//!
14//! # Example
15//! ```rust
16//! use std::time::Duration;
17//! use taskvisor::{BackoffPolicy, JitterPolicy};
18//!
19//! let backoff = BackoffPolicy {
20//!     first: Duration::from_millis(100),
21//!     max: Duration::from_secs(10),
22//!     factor: 2.0,
23//!     jitter: JitterPolicy::None,
24//! };
25//!
26//! // Attempt 0 — uses 'first' (100ms), clamped to max
27//! assert_eq!(backoff.next(0), Duration::from_millis(100));
28//!
29//! // Attempt 1 — first × factor^1 = 200ms
30//! assert_eq!(backoff.next(1), Duration::from_millis(200));
31//!
32//! // Attempt 10 — 100ms × 2^10 = 102_400ms → capped at max=10s
33//! assert_eq!(backoff.next(10), Duration::from_secs(10));
34//! ```
35
36use std::time::Duration;
37
38use crate::policies::jitter::JitterPolicy;
39
40/// Retry backoff policy.
41///
42/// Encapsulates parameters that determine how retry delays grow:
43/// - [`BackoffPolicy::factor`] — multiplicative growth factor;
44/// - [`BackoffPolicy::first`] — the initial delay;
45/// - [`BackoffPolicy::max`] — the maximum delay cap.
46#[derive(Clone, Copy, Debug)]
47pub struct BackoffPolicy {
48    /// Initial delay before the first retry.
49    pub first: Duration,
50    /// Maximum delay cap for retries.
51    pub max: Duration,
52    /// Multiplicative growth factor (`>= 1.0` recommended).
53    pub factor: f64,
54    /// Jitter policy to prevent thundering herd.
55    pub jitter: JitterPolicy,
56}
57
58impl Default for BackoffPolicy {
59    /// Returns a strategy with:
60    /// - `factor = 1.0` (constant delay);
61    /// - `first = 100ms`;
62    /// - `max = 30s`.
63    fn default() -> Self {
64        Self {
65            first: Duration::from_millis(100),
66            max: Duration::from_secs(30),
67            jitter: JitterPolicy::None,
68            factor: 1.0,
69        }
70    }
71}
72
73impl BackoffPolicy {
74    /// Computes the delay for the given attempt number (0-indexed).
75    ///
76    /// The base delay is `first × factor^attempt`, clamped to [`BackoffPolicy::max`].
77    /// Jitter is applied to the clamped base, but the result is **never** fed back
78    /// into subsequent calculations — each attempt derives its base independently.
79    ///
80    /// # Notes
81    /// - If `factor` is less than 1.0, delays decrease with higher attempts (not typical).
82    /// - If `factor` equals 1.0, delay remains constant at `first` (up to `max`).
83    /// - If `factor` is greater than 1.0, delays grow exponentially up to `max`.
84    pub fn next(&self, attempt: u32) -> Duration {
85        let max_secs = self.max.as_secs_f64();
86        let clamped_exp = attempt.min(i32::MAX as u32) as i32;
87        let unclamped_secs = self.first.as_secs_f64() * self.factor.powi(clamped_exp);
88
89        let base =
90            if !unclamped_secs.is_finite() || unclamped_secs < 0.0 || unclamped_secs > max_secs {
91                self.max
92            } else {
93                Duration::from_secs_f64(unclamped_secs)
94            };
95
96        match self.jitter {
97            JitterPolicy::Decorrelated => {
98                self.jitter
99                    .apply_decorrelated(self.first.min(self.max), base, self.max)
100            }
101            _ => self.jitter.apply(base),
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use std::time::Duration;
110
111    #[test]
112    fn test_attempt_zero_returns_first() {
113        let policy = BackoffPolicy {
114            first: Duration::from_millis(100),
115            max: Duration::from_secs(30),
116            factor: 2.0,
117            jitter: JitterPolicy::None,
118        };
119        assert_eq!(policy.next(0), Duration::from_millis(100));
120    }
121
122    #[test]
123    fn test_exponential_growth_no_jitter() {
124        let policy = BackoffPolicy {
125            first: Duration::from_millis(100),
126            max: Duration::from_secs(30),
127            factor: 2.0,
128            jitter: JitterPolicy::None,
129        };
130
131        assert_eq!(policy.next(0), Duration::from_millis(100));
132        assert_eq!(policy.next(1), Duration::from_millis(200));
133        assert_eq!(policy.next(2), Duration::from_millis(400));
134        assert_eq!(policy.next(3), Duration::from_millis(800));
135        assert_eq!(policy.next(4), Duration::from_millis(1600));
136    }
137
138    #[test]
139    fn test_constant_factor() {
140        let policy = BackoffPolicy {
141            first: Duration::from_millis(500),
142            max: Duration::from_secs(30),
143            factor: 1.0,
144            jitter: JitterPolicy::None,
145        };
146        for attempt in 0..10 {
147            assert_eq!(
148                policy.next(attempt),
149                Duration::from_millis(500),
150                "attempt {} should be constant at 500ms",
151                attempt
152            );
153        }
154    }
155
156    #[test]
157    fn test_clamped_to_max() {
158        let policy = BackoffPolicy {
159            first: Duration::from_millis(100),
160            max: Duration::from_secs(1),
161            factor: 2.0,
162            jitter: JitterPolicy::None,
163        };
164        assert_eq!(policy.next(10), Duration::from_secs(1));
165    }
166
167    #[test]
168    fn test_first_exceeds_max() {
169        let policy = BackoffPolicy {
170            first: Duration::from_secs(10),
171            max: Duration::from_secs(5),
172            factor: 2.0,
173            jitter: JitterPolicy::None,
174        };
175        assert_eq!(policy.next(0), Duration::from_secs(5));
176    }
177
178    #[test]
179    fn test_full_jitter_no_negative_feedback() {
180        let policy = BackoffPolicy {
181            first: Duration::from_millis(100),
182            max: Duration::from_secs(30),
183            factor: 2.0,
184            jitter: JitterPolicy::Full,
185        };
186
187        for attempt in 5..15 {
188            let base_ms = 100.0 * 2.0f64.powi(attempt as i32);
189            let delay = policy.next(attempt);
190            assert!(
191                delay <= Duration::from_millis(base_ms as u64),
192                "attempt {}: delay {:?} exceeds base {}ms",
193                attempt,
194                delay,
195                base_ms
196            );
197        }
198    }
199
200    #[test]
201    fn test_equal_jitter_no_negative_feedback() {
202        let policy = BackoffPolicy {
203            first: Duration::from_millis(100),
204            max: Duration::from_secs(30),
205            factor: 2.0,
206            jitter: JitterPolicy::Equal,
207        };
208
209        for attempt in 0..15 {
210            let base_ms = (100.0 * 2.0f64.powi(attempt as i32)).min(30_000.0);
211            let half = base_ms / 2.0;
212            let delay = policy.next(attempt);
213            assert!(
214                delay >= Duration::from_millis(half as u64),
215                "attempt {}: delay {:?} < half of base {}ms",
216                attempt,
217                delay,
218                base_ms
219            );
220            assert!(
221                delay <= Duration::from_millis(base_ms as u64),
222                "attempt {}: delay {:?} > base {}ms",
223                attempt,
224                delay,
225                base_ms
226            );
227        }
228    }
229
230    #[test]
231    fn test_decorrelated_jitter_grows_with_attempts() {
232        let policy = BackoffPolicy {
233            first: Duration::from_millis(100),
234            max: Duration::from_secs(30),
235            factor: 2.0,
236            jitter: JitterPolicy::Decorrelated,
237        };
238
239        let mut min_late = Duration::from_secs(999);
240        let mut max_late = Duration::ZERO;
241        for _ in 0..100 {
242            let delay = policy.next(8);
243            min_late = min_late.min(delay);
244            max_late = max_late.max(delay);
245        }
246
247        assert!(
248            min_late >= Duration::from_millis(100),
249            "min_late {:?} below floor",
250            min_late
251        );
252        assert!(
253            max_late >= Duration::from_secs(5),
254            "max_late {:?} suspiciously low, range too narrow",
255            max_late
256        );
257    }
258
259    #[test]
260    fn test_full_jitter_bounds() {
261        let policy = BackoffPolicy {
262            first: Duration::from_millis(1000),
263            max: Duration::from_secs(30),
264            factor: 1.0,
265            jitter: JitterPolicy::Full,
266        };
267        for attempt in 0..50 {
268            let delay = policy.next(attempt);
269            assert!(delay <= Duration::from_millis(1000));
270        }
271    }
272
273    #[test]
274    fn test_equal_jitter_bounds() {
275        let policy = BackoffPolicy {
276            first: Duration::from_millis(1000),
277            max: Duration::from_secs(30),
278            factor: 1.0,
279            jitter: JitterPolicy::Equal,
280        };
281        for attempt in 0..50 {
282            let delay = policy.next(attempt);
283            assert!(delay >= Duration::from_millis(500));
284            assert!(delay <= Duration::from_millis(1000));
285        }
286    }
287
288    #[test]
289    fn test_huge_attempt_clamps_to_max() {
290        let policy = BackoffPolicy {
291            first: Duration::from_millis(100),
292            max: Duration::from_secs(60),
293            factor: 2.0,
294            jitter: JitterPolicy::None,
295        };
296        assert_eq!(policy.next(100), Duration::from_secs(60));
297    }
298
299    #[test]
300    fn test_non_finite_overflow_clamps_to_max() {
301        let policy = BackoffPolicy {
302            first: Duration::from_millis(100),
303            max: Duration::from_secs(10),
304            factor: 2.0,
305            jitter: JitterPolicy::None,
306        };
307        assert_eq!(policy.next(u32::MAX), Duration::from_secs(10));
308    }
309}