Skip to main content

queuey_core/
retry.rs

1//! Retry configuration: [`RetryPolicy`], [`Backoff`] and the backoff math.
2
3use std::time::Duration;
4
5use serde::{Deserialize, Serialize};
6
7/// How long to wait between attempts.
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9pub enum Backoff {
10    /// Retry immediately.
11    None,
12    /// Constant delay between attempts.
13    Fixed(Duration),
14    /// `delay = min(max, base * factor^(attempt-1))`, optionally with full jitter.
15    Exponential {
16        /// Delay used for the first retry (after attempt 1 failed).
17        base: Duration,
18        /// Multiplier applied once per previous attempt.
19        factor: f64,
20        /// Upper bound of the computed delay, jitter included.
21        max: Duration,
22        /// When `true`, the delay is drawn uniformly from `[0, computed]`.
23        jitter: bool,
24    },
25}
26
27impl Backoff {
28    /// Sensible exponential default: 1s base, x2, capped at 5 minutes, jittered.
29    pub fn exponential() -> Self {
30        Self::Exponential {
31            base: Duration::from_secs(1),
32            factor: 2.0,
33            max: Duration::from_secs(300),
34            jitter: true,
35        }
36    }
37
38    /// Delay before the *next* attempt, given the attempt that just failed (1-based).
39    /// Must never panic and must return a value <= `max` for the exponential variant.
40    /// Jitter, when enabled, picks uniformly in `[0, computed]`.
41    pub fn delay_for(&self, failed_attempt: u32) -> Duration {
42        crate::retry::compute_delay(self, failed_attempt)
43    }
44}
45
46/// Retry configuration attached to a queue or a job.
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48pub struct RetryPolicy {
49    /// Total attempts including the first. `1` means no retries.
50    pub max_attempts: u32,
51    /// How long to wait before each retry.
52    pub backoff: Backoff,
53}
54
55impl Default for RetryPolicy {
56    /// No retries.
57    fn default() -> Self {
58        Self {
59            max_attempts: 1,
60            backoff: Backoff::None,
61        }
62    }
63}
64
65/// Outcome of consulting a policy after a failure.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum RetryDecision {
68    /// Re-publish after `delay`.
69    Retry {
70        /// How long to wait before the next attempt.
71        delay: Duration,
72    },
73    /// Attempts exhausted; dead-letter.
74    GiveUp,
75}
76
77impl RetryPolicy {
78    /// A policy allowing `max_attempts` total attempts with the given backoff.
79    pub fn new(max_attempts: u32, backoff: Backoff) -> Self {
80        Self {
81            max_attempts,
82            backoff,
83        }
84    }
85    /// No retries: a single attempt, no delay.
86    pub fn none() -> Self {
87        Self::default()
88    }
89    /// `max_attempts` attempts with the default exponential backoff.
90    pub fn exponential(max_attempts: u32) -> Self {
91        Self {
92            max_attempts,
93            backoff: Backoff::exponential(),
94        }
95    }
96    /// `max_attempts` attempts with a constant `delay` between them.
97    pub fn fixed(max_attempts: u32, delay: Duration) -> Self {
98        Self {
99            max_attempts,
100            backoff: Backoff::Fixed(delay),
101        }
102    }
103
104    /// Decide what to do after `failed_attempt` (1-based) failed.
105    pub fn decide(&self, failed_attempt: u32) -> RetryDecision {
106        if failed_attempt >= self.max_attempts {
107            RetryDecision::GiveUp
108        } else {
109            RetryDecision::Retry {
110                delay: self.backoff.delay_for(failed_attempt),
111            }
112        }
113    }
114}
115
116/// Pure backoff math shared by [`Backoff::delay_for`].
117///
118/// Never panics: `failed_attempt` is clamped to at least 1, the exponentiation is
119/// done in `f64` (saturating to `max` on overflow) and any non-finite intermediate
120/// result falls back to `max`.
121pub(crate) fn compute_delay(backoff: &Backoff, failed_attempt: u32) -> Duration {
122    match backoff {
123        Backoff::None => Duration::ZERO,
124        Backoff::Fixed(delay) => *delay,
125        Backoff::Exponential {
126            base,
127            factor,
128            max,
129            jitter,
130        } => {
131            let computed = exponential_delay(*base, *factor, *max, failed_attempt);
132            if *jitter {
133                full_jitter(computed)
134            } else {
135                computed
136            }
137        }
138    }
139}
140
141/// `min(max, base * factor^(failed_attempt - 1))`, overflow-safe.
142fn exponential_delay(base: Duration, factor: f64, max: Duration, failed_attempt: u32) -> Duration {
143    if base.is_zero() || max.is_zero() {
144        return Duration::ZERO;
145    }
146    // Attempt 0 is nonsensical but must not underflow; treat it like attempt 1.
147    let exponent = f64::from(failed_attempt.max(1) - 1);
148    // A non-finite or non-positive factor degenerates to a constant `base`.
149    let factor = if factor.is_finite() && factor > 0.0 {
150        factor
151    } else {
152        1.0
153    };
154
155    let secs = base.as_secs_f64() * factor.powf(exponent);
156    if !secs.is_finite() {
157        // Overflowed to +inf (or produced a NaN); the cap is the only sane answer.
158        return max;
159    }
160    let capped = secs.clamp(0.0, max.as_secs_f64());
161    Duration::try_from_secs_f64(capped).unwrap_or(max).min(max)
162}
163
164/// Full jitter: uniformly distributed in `[0, delay]`.
165fn full_jitter(delay: Duration) -> Duration {
166    use rand::RngExt;
167
168    let secs = delay.as_secs_f64();
169    if !secs.is_finite() || secs <= 0.0 {
170        return Duration::ZERO;
171    }
172    let sampled = rand::rng().random_range(0.0..=secs);
173    Duration::try_from_secs_f64(sampled)
174        .unwrap_or(delay)
175        .min(delay)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    const MS: fn(u64) -> Duration = Duration::from_millis;
183
184    #[test]
185    fn none_is_zero() {
186        assert_eq!(Backoff::None.delay_for(1), Duration::ZERO);
187        assert_eq!(Backoff::None.delay_for(0), Duration::ZERO);
188        assert_eq!(Backoff::None.delay_for(u32::MAX), Duration::ZERO);
189    }
190
191    #[test]
192    fn fixed_is_constant() {
193        let b = Backoff::Fixed(MS(250));
194        for attempt in [0, 1, 2, 7, u32::MAX] {
195            assert_eq!(b.delay_for(attempt), MS(250));
196        }
197    }
198
199    #[test]
200    fn exponential_first_attempt_is_base() {
201        let b = Backoff::Exponential {
202            base: MS(500),
203            factor: 3.0,
204            max: Duration::from_secs(600),
205            jitter: false,
206        };
207        assert_eq!(b.delay_for(1), MS(500));
208    }
209
210    #[test]
211    fn exponential_grows_by_factor() {
212        let b = Backoff::Exponential {
213            base: Duration::from_secs(1),
214            factor: 2.0,
215            max: Duration::from_secs(600),
216            jitter: false,
217        };
218        assert_eq!(b.delay_for(1), Duration::from_secs(1));
219        assert_eq!(b.delay_for(2), Duration::from_secs(2));
220        assert_eq!(b.delay_for(3), Duration::from_secs(4));
221        assert_eq!(b.delay_for(4), Duration::from_secs(8));
222    }
223
224    #[test]
225    fn exponential_respects_cap() {
226        let max = Duration::from_secs(10);
227        let b = Backoff::Exponential {
228            base: Duration::from_secs(1),
229            factor: 2.0,
230            max,
231            jitter: false,
232        };
233        assert_eq!(b.delay_for(5), Duration::from_secs(10));
234        assert_eq!(b.delay_for(50), max);
235        assert_eq!(b.delay_for(u32::MAX), max);
236    }
237
238    #[test]
239    fn exponential_attempt_zero_is_base() {
240        let b = Backoff::Exponential {
241            base: MS(120),
242            factor: 2.0,
243            max: Duration::from_secs(60),
244            jitter: false,
245        };
246        assert_eq!(b.delay_for(0), MS(120));
247    }
248
249    #[test]
250    fn exponential_handles_degenerate_factors() {
251        let max = Duration::from_secs(60);
252        for factor in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 0.0, -2.0] {
253            let b = Backoff::Exponential {
254                base: MS(100),
255                factor,
256                max,
257                jitter: false,
258            };
259            for attempt in [0, 1, 2, 1000, u32::MAX] {
260                let d = b.delay_for(attempt);
261                assert!(d <= max, "factor {factor} attempt {attempt} gave {d:?}");
262            }
263            // Degenerate factors collapse to a constant `base`.
264            assert_eq!(b.delay_for(3), MS(100), "factor {factor}");
265        }
266    }
267
268    #[test]
269    fn exponential_shrinking_factor_never_underflows() {
270        let b = Backoff::Exponential {
271            base: Duration::from_secs(1),
272            factor: 0.5,
273            max: Duration::from_secs(60),
274            jitter: false,
275        };
276        assert_eq!(b.delay_for(2), MS(500));
277        assert_eq!(b.delay_for(u32::MAX), Duration::ZERO);
278    }
279
280    #[test]
281    fn exponential_zero_base_or_max_is_zero() {
282        let a = Backoff::Exponential {
283            base: Duration::ZERO,
284            factor: 2.0,
285            max: Duration::from_secs(60),
286            jitter: true,
287        };
288        let b = Backoff::Exponential {
289            base: Duration::from_secs(1),
290            factor: 2.0,
291            max: Duration::ZERO,
292            jitter: false,
293        };
294        for attempt in [0, 1, 9, u32::MAX] {
295            assert_eq!(a.delay_for(attempt), Duration::ZERO);
296            assert_eq!(b.delay_for(attempt), Duration::ZERO);
297        }
298    }
299
300    #[test]
301    fn jitter_stays_within_bounds() {
302        let max = Duration::from_secs(30);
303        let b = Backoff::Exponential {
304            base: Duration::from_secs(1),
305            factor: 2.0,
306            max,
307            jitter: true,
308        };
309        let mut saw_below_cap = false;
310        for _ in 0..2_000 {
311            let d = b.delay_for(3);
312            assert!(d <= Duration::from_secs(4), "{d:?} exceeded uncapped value");
313            let capped = b.delay_for(99);
314            assert!(capped <= max, "{capped:?} exceeded max");
315            if d < Duration::from_secs(4) {
316                saw_below_cap = true;
317            }
318        }
319        assert!(
320            saw_below_cap,
321            "jitter never produced a value below the computed delay"
322        );
323    }
324
325    #[test]
326    fn jitter_is_not_constant() {
327        let b = Backoff::Exponential {
328            base: Duration::from_secs(10),
329            factor: 2.0,
330            max: Duration::from_secs(600),
331            jitter: true,
332        };
333        let first = b.delay_for(4);
334        let differs = (0..100).any(|_| b.delay_for(4) != first);
335        assert!(differs, "jitter produced the same value 100 times");
336    }
337
338    #[test]
339    fn huge_attempts_do_not_panic_with_jitter() {
340        let b = Backoff::exponential();
341        for attempt in [0, 1, u32::MAX / 2, u32::MAX] {
342            let d = b.delay_for(attempt);
343            assert!(d <= Duration::from_secs(300));
344        }
345    }
346
347    #[test]
348    fn decide_single_attempt_gives_up_immediately() {
349        let p = RetryPolicy::new(1, Backoff::Fixed(Duration::from_secs(1)));
350        assert_eq!(p.decide(1), RetryDecision::GiveUp);
351    }
352
353    #[test]
354    fn decide_boundaries_for_three_attempts() {
355        let p = RetryPolicy::new(3, Backoff::Fixed(Duration::from_secs(2)));
356        assert_eq!(
357            p.decide(1),
358            RetryDecision::Retry {
359                delay: Duration::from_secs(2)
360            }
361        );
362        assert_eq!(
363            p.decide(2),
364            RetryDecision::Retry {
365                delay: Duration::from_secs(2)
366            }
367        );
368        assert_eq!(p.decide(3), RetryDecision::GiveUp);
369        assert_eq!(p.decide(4), RetryDecision::GiveUp);
370        assert_eq!(p.decide(u32::MAX), RetryDecision::GiveUp);
371    }
372
373    #[test]
374    fn default_policy_has_no_retries() {
375        let p = RetryPolicy::default();
376        assert_eq!(p.max_attempts, 1);
377        assert_eq!(p.backoff, Backoff::None);
378        assert_eq!(p.decide(1), RetryDecision::GiveUp);
379    }
380
381    #[test]
382    fn decide_uses_the_failed_attempt_for_the_delay() {
383        let p = RetryPolicy::new(
384            5,
385            Backoff::Exponential {
386                base: Duration::from_secs(1),
387                factor: 2.0,
388                max: Duration::from_secs(600),
389                jitter: false,
390            },
391        );
392        assert_eq!(
393            p.decide(1),
394            RetryDecision::Retry {
395                delay: Duration::from_secs(1)
396            }
397        );
398        assert_eq!(
399            p.decide(3),
400            RetryDecision::Retry {
401                delay: Duration::from_secs(4)
402            }
403        );
404    }
405}