Skip to main content

execution_policy/retry/
mod.rs

1//! Retry policy, backoff schedules, and jitter.
2
3pub mod backoff;
4pub mod budget;
5pub mod jitter;
6
7pub use backoff::Backoff;
8pub use budget::RetryBudget;
9pub use jitter::Jitter;
10
11use std::time::Duration;
12
13use crate::classify::{Classifier, RetryAfterExtractor, RetryDecision};
14use crate::core::Core;
15
16/// Retry configuration: attempt cap, backoff, jitter, classification, budget.
17pub struct Retry<T, E> {
18    max_attempts: u32,
19    max_elapsed: Option<Duration>,
20    backoff: Backoff,
21    jitter: Jitter,
22    classifier: Classifier<T, E>,
23    budget: Option<RetryBudget>,
24    retry_after: Option<RetryAfterExtractor<E>>,
25}
26
27impl<T, E> std::fmt::Debug for Retry<T, E> {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        f.debug_struct("Retry")
30            .field("max_attempts", &self.max_attempts)
31            .field("max_elapsed", &self.max_elapsed)
32            .field("backoff", &self.backoff)
33            .field("jitter", &self.jitter)
34            .field("classifier", &self.classifier)
35            .field("budget", &self.budget)
36            .field("retry_after", &self.retry_after.as_ref().map(|_| "<fn>"))
37            .finish()
38    }
39}
40
41impl<T, E> Retry<T, E> {
42    fn base(max_attempts: u32, backoff: Backoff) -> Self {
43        Self {
44            max_attempts,
45            max_elapsed: None,
46            backoff,
47            jitter: Jitter::None,
48            classifier: Classifier::RetryAll,
49            budget: None,
50            retry_after: None,
51        }
52    }
53
54    /// No retries โ€” a single attempt.
55    pub fn none() -> Self {
56        Self::base(1, Backoff::fixed(Duration::ZERO))
57    }
58    /// Constant-delay retries (default 3 attempts).
59    pub fn fixed(delay: Duration) -> Self {
60        Self::base(3, Backoff::fixed(delay))
61    }
62    /// Exponential backoff (default 3 attempts, 100ms base, 10s max, no jitter).
63    pub fn exponential() -> Self {
64        Self::base(
65            3,
66            Backoff::exponential(Duration::from_millis(100), Duration::from_secs(10)),
67        )
68    }
69    /// Sensible default schedule: exponential + full jitter, 4 attempts.
70    /// Pair with `.when(..)` for transient-only classification (see spec ยง6).
71    pub fn standard() -> Self {
72        Self::base(
73            4,
74            Backoff::exponential(Duration::from_millis(100), Duration::from_secs(2)),
75        )
76        .jitter(Jitter::Full)
77    }
78
79    pub fn max_attempts(mut self, n: u32) -> Self {
80        self.max_attempts = n;
81        self
82    }
83    pub fn max_elapsed(mut self, d: Duration) -> Self {
84        self.max_elapsed = Some(d);
85        self
86    }
87    pub fn base_delay(mut self, d: Duration) -> Self {
88        self.backoff = match self.backoff {
89            Backoff::Exponential { max, .. } => Backoff::Exponential { base: d, max },
90            Backoff::Fixed(_) => Backoff::Fixed(d),
91        };
92        self
93    }
94    pub fn max_delay(mut self, d: Duration) -> Self {
95        if let Backoff::Exponential { base, .. } = self.backoff {
96            self.backoff = Backoff::Exponential { base, max: d };
97        }
98        self
99    }
100    pub fn jitter(mut self, j: Jitter) -> Self {
101        self.jitter = j;
102        self
103    }
104    /// Attach a shared [`RetryBudget`] to bound retries across calls.
105    pub fn budget(mut self, budget: RetryBudget) -> Self {
106        self.budget = Some(budget);
107        self
108    }
109    pub fn when(mut self, pred: impl Fn(&E) -> bool + Send + Sync + 'static) -> Self {
110        self.classifier = Classifier::WhenErr(Box::new(pred));
111        self
112    }
113    pub fn when_outcome(
114        mut self,
115        f: impl Fn(&Result<T, E>) -> RetryDecision + Send + Sync + 'static,
116    ) -> Self {
117        self.classifier = Classifier::WhenOutcome(Box::new(f));
118        self
119    }
120
121    /// Extract an explicit delay hint from an error (e.g. a server-supplied
122    /// `Retry-After` duration). When the extractor returns `Some(hint)`, the
123    /// next delay is `max(backoff, hint)` โ€” the hint acts as a **floor**.
124    /// The result is still capped by `max_backoff` (if set) and will not
125    /// exceed the remaining `total_timeout` budget (the engine stops instead
126    /// of overshooting). Jitter applies normally on top of the chosen delay.
127    ///
128    /// No HTTP, gRPC, or any other dependency is introduced โ€” the closure
129    /// receives `&E` and you parse whatever field you need.
130    pub fn retry_after(
131        mut self,
132        f: impl Fn(&E) -> Option<Duration> + Send + Sync + 'static,
133    ) -> Self {
134        self.retry_after = Some(Box::new(f));
135        self
136    }
137
138    pub(crate) fn max_attempts_value(&self) -> u32 {
139        self.max_attempts
140    }
141    pub(crate) fn max_elapsed_value(&self) -> Option<Duration> {
142        self.max_elapsed
143    }
144    pub(crate) fn decide(&self, outcome: &Result<T, E>) -> RetryDecision {
145        self.classifier.decide(outcome)
146    }
147    /// Compute the next backoff delay.
148    ///
149    /// `last_err` is passed through to the retry-after extractor (if any).
150    /// When the extractor returns `Some(hint)`, the raw backoff is floored to
151    /// `hint` before jitter is applied. The cap at `max_backoff` (encoded in
152    /// `Backoff::Exponential`) already happens inside `raw_delay`; the
153    /// `total_timeout` budget check is done by the caller in `engine.rs`.
154    pub(crate) fn delay(&self, attempt: u32, core: &dyn Core, last_err: Option<&E>) -> Duration {
155        let backoff_raw = self.backoff.raw_delay(attempt);
156        // Apply retry-after hint as a floor (hint wins if larger).
157        let hint = last_err
158            .zip(self.retry_after.as_deref())
159            .and_then(|(e, f)| f(e));
160        let raw = match hint {
161            Some(h) if h > backoff_raw => h,
162            _ => backoff_raw,
163        };
164        self.jitter.apply(raw, core.next_u64())
165    }
166    pub(crate) fn budget_ref(&self) -> Option<&RetryBudget> {
167        self.budget.as_ref()
168    }
169    /// Returns the retry-after extractor, if any, for use in budget-stop checks.
170    pub(crate) fn retry_after_hint(&self, err: &E) -> Option<Duration> {
171        self.retry_after.as_deref().and_then(|f| f(err))
172    }
173}
174
175#[cfg(all(test, feature = "test-util"))]
176mod retry_tests {
177    use super::*;
178    use crate::core::{ManualClock, TestCore};
179
180    #[test]
181    fn presets_have_expected_attempt_caps() {
182        assert_eq!(Retry::<(), ()>::none().max_attempts_value(), 1);
183        assert_eq!(
184            Retry::<(), ()>::fixed(Duration::ZERO).max_attempts_value(),
185            3
186        );
187        assert_eq!(Retry::<(), ()>::exponential().max_attempts_value(), 3);
188        assert_eq!(Retry::<(), ()>::standard().max_attempts_value(), 4);
189    }
190
191    #[test]
192    fn builder_overrides_schedule() {
193        let r = Retry::<u32, &str>::exponential()
194            .max_attempts(5)
195            .base_delay(Duration::from_millis(20))
196            .max_delay(Duration::from_millis(80));
197        assert_eq!(r.max_attempts_value(), 5);
198        let core = TestCore::new(ManualClock::new());
199        assert_eq!(r.delay(1, &core, None), Duration::from_millis(20));
200        assert_eq!(r.delay(3, &core, None), Duration::from_millis(80));
201        assert_eq!(r.delay(9, &core, None), Duration::from_millis(80));
202    }
203
204    #[test]
205    fn when_predicate_controls_decision() {
206        let r = Retry::<u32, i32>::exponential().when(|e: &i32| *e >= 500);
207        assert_eq!(r.decide(&Err(503)), RetryDecision::Retry);
208        assert_eq!(r.decide(&Err(404)), RetryDecision::Stop);
209        assert_eq!(r.decide(&Ok(1)), RetryDecision::Stop);
210    }
211}