Skip to main content

actionqueue_executor_local/
backoff.rs

1//! Backoff strategies for retry delay computation.
2//!
3//! This module provides pluggable backoff strategies that compute the delay
4//! before a retried run becomes eligible for re-promotion from `RetryWait`
5//! to `Ready`.
6
7use std::time::Duration;
8
9/// Error returned when backoff configuration is invalid.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum BackoffConfigError {
12    /// Base delay exceeds maximum delay.
13    BaseExceedsMax {
14        /// The base delay that was too large.
15        base: Duration,
16        /// The maximum delay that was exceeded.
17        max: Duration,
18    },
19}
20
21impl std::fmt::Display for BackoffConfigError {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        match self {
24            BackoffConfigError::BaseExceedsMax { base, max } => {
25                write!(f, "base delay ({base:?}) must not exceed max delay ({max:?})")
26            }
27        }
28    }
29}
30
31impl std::error::Error for BackoffConfigError {}
32
33/// A strategy for computing retry delay based on attempt number.
34pub trait BackoffStrategy: Send + Sync {
35    /// Returns the delay before the given attempt should be retried.
36    ///
37    /// `attempt_number` is 1-indexed: the first retry after the initial attempt
38    /// is `attempt_number = 1`.
39    fn delay_for_attempt(&self, attempt_number: u32) -> Duration;
40}
41
42/// Fixed-interval backoff: every retry waits the same duration.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct FixedBackoff {
45    /// The constant interval between retries.
46    interval: Duration,
47}
48
49impl FixedBackoff {
50    /// Creates a new fixed backoff strategy with the given interval.
51    pub fn new(interval: Duration) -> Self {
52        Self { interval }
53    }
54
55    /// Returns the constant interval between retries.
56    pub fn interval(&self) -> Duration {
57        self.interval
58    }
59}
60
61impl BackoffStrategy for FixedBackoff {
62    fn delay_for_attempt(&self, _attempt_number: u32) -> Duration {
63        self.interval
64    }
65}
66
67/// Exponential backoff: delay doubles with each attempt, capped at a maximum.
68///
69/// The delay for attempt N is `min(base * 2^(N-1), max)`.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct ExponentialBackoff {
72    base: Duration,
73    max: Duration,
74}
75
76impl ExponentialBackoff {
77    /// Creates a new exponential backoff strategy.
78    ///
79    /// # Errors
80    /// Returns [`BackoffConfigError::BaseExceedsMax`] if `base` exceeds `max`.
81    pub fn new(base: Duration, max: Duration) -> Result<Self, BackoffConfigError> {
82        if base > max {
83            return Err(BackoffConfigError::BaseExceedsMax { base, max });
84        }
85        Ok(Self { base, max })
86    }
87
88    /// Returns the base delay.
89    pub fn base(&self) -> Duration {
90        self.base
91    }
92
93    /// Returns the maximum delay cap.
94    pub fn max(&self) -> Duration {
95        self.max
96    }
97}
98
99impl BackoffStrategy for ExponentialBackoff {
100    fn delay_for_attempt(&self, attempt_number: u32) -> Duration {
101        debug_assert!(attempt_number >= 1, "attempt_number is 1-indexed");
102        let exponent = attempt_number.saturating_sub(1);
103        let multiplier = 1u64.checked_shl(exponent).unwrap_or(u64::MAX);
104        let base_millis = u64::try_from(self.base.as_millis()).unwrap_or(u64::MAX);
105        let delay_millis = base_millis.saturating_mul(multiplier);
106        let delay = Duration::from_millis(delay_millis);
107        delay.min(self.max)
108    }
109}
110
111/// Computes the timestamp at which a retried run becomes ready.
112///
113/// `retry_wait_entered_at` is the timestamp when the run entered `RetryWait`.
114/// Returns `retry_wait_entered_at + backoff_delay`, saturating at `u64::MAX`.
115pub fn retry_ready_at(
116    retry_wait_entered_at: u64,
117    attempt_number: u32,
118    strategy: &dyn BackoffStrategy,
119) -> u64 {
120    let delay = strategy.delay_for_attempt(attempt_number);
121    let delay_secs = delay.as_secs().saturating_add(u64::from(delay.subsec_nanos() > 0));
122    retry_wait_entered_at.saturating_add(delay_secs)
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn fixed_backoff_returns_constant_delay() {
131        let strategy = FixedBackoff::new(Duration::from_secs(5));
132
133        assert_eq!(strategy.delay_for_attempt(1), Duration::from_secs(5));
134        assert_eq!(strategy.delay_for_attempt(2), Duration::from_secs(5));
135        assert_eq!(strategy.delay_for_attempt(100), Duration::from_secs(5));
136    }
137
138    #[test]
139    fn exponential_backoff_doubles_per_attempt() {
140        let strategy =
141            ExponentialBackoff::new(Duration::from_secs(1), Duration::from_secs(3600)).unwrap();
142
143        assert_eq!(strategy.delay_for_attempt(1), Duration::from_secs(1)); // 1 * 2^0
144        assert_eq!(strategy.delay_for_attempt(2), Duration::from_secs(2)); // 1 * 2^1
145        assert_eq!(strategy.delay_for_attempt(3), Duration::from_secs(4)); // 1 * 2^2
146        assert_eq!(strategy.delay_for_attempt(4), Duration::from_secs(8)); // 1 * 2^3
147    }
148
149    #[test]
150    fn exponential_backoff_caps_at_max() {
151        let strategy =
152            ExponentialBackoff::new(Duration::from_secs(1), Duration::from_secs(10)).unwrap();
153
154        assert_eq!(strategy.delay_for_attempt(1), Duration::from_secs(1));
155        assert_eq!(strategy.delay_for_attempt(4), Duration::from_secs(8));
156        assert_eq!(strategy.delay_for_attempt(5), Duration::from_secs(10)); // capped
157        assert_eq!(strategy.delay_for_attempt(100), Duration::from_secs(10)); // still capped
158    }
159
160    #[test]
161    fn exponential_backoff_saturates_on_overflow() {
162        let strategy =
163            ExponentialBackoff::new(Duration::from_secs(1000), Duration::from_millis(u64::MAX))
164                .unwrap();
165
166        // Very high attempt should saturate, not panic
167        let delay = strategy.delay_for_attempt(u32::MAX);
168        assert!(delay.as_millis() > 0);
169    }
170
171    #[test]
172    fn retry_ready_at_adds_delay_to_current_time() {
173        let strategy = FixedBackoff::new(Duration::from_secs(30));
174
175        assert_eq!(retry_ready_at(1000, 1, &strategy), 1030);
176        assert_eq!(retry_ready_at(1000, 5, &strategy), 1030);
177    }
178
179    #[test]
180    fn retry_ready_at_saturates_at_u64_max() {
181        let strategy = FixedBackoff::new(Duration::from_secs(100));
182
183        assert_eq!(retry_ready_at(u64::MAX - 10, 1, &strategy), u64::MAX);
184    }
185
186    #[test]
187    fn retry_ready_at_rounds_up_sub_second_delay() {
188        let strategy = FixedBackoff::new(Duration::from_millis(500));
189
190        // 500ms should round up to 1 second, not truncate to 0
191        assert_eq!(retry_ready_at(1000, 1, &strategy), 1001);
192    }
193
194    #[test]
195    fn retry_ready_at_exact_seconds_not_rounded_up() {
196        let strategy = FixedBackoff::new(Duration::from_secs(3));
197
198        // Exact 3s should produce exactly +3, not +4
199        assert_eq!(retry_ready_at(1000, 1, &strategy), 1003);
200    }
201
202    #[test]
203    fn retry_ready_at_exponential_produces_increasing_delays() {
204        let strategy =
205            ExponentialBackoff::new(Duration::from_secs(10), Duration::from_secs(3600)).unwrap();
206
207        let t1 = retry_ready_at(1000, 1, &strategy);
208        let t2 = retry_ready_at(1000, 2, &strategy);
209        let t3 = retry_ready_at(1000, 3, &strategy);
210
211        assert_eq!(t1, 1010); // 1000 + 10
212        assert_eq!(t2, 1020); // 1000 + 20
213        assert_eq!(t3, 1040); // 1000 + 40
214        assert!(t1 < t2);
215        assert!(t2 < t3);
216    }
217}