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, 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}
25
26impl<T, E> std::fmt::Debug for Retry<T, E> {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        f.debug_struct("Retry")
29            .field("max_attempts", &self.max_attempts)
30            .field("max_elapsed", &self.max_elapsed)
31            .field("backoff", &self.backoff)
32            .field("jitter", &self.jitter)
33            .field("classifier", &self.classifier)
34            .field("budget", &self.budget)
35            .finish()
36    }
37}
38
39impl<T, E> Retry<T, E> {
40    fn base(max_attempts: u32, backoff: Backoff) -> Self {
41        Self {
42            max_attempts,
43            max_elapsed: None,
44            backoff,
45            jitter: Jitter::None,
46            classifier: Classifier::RetryAll,
47            budget: None,
48        }
49    }
50
51    /// No retries — a single attempt.
52    pub fn none() -> Self {
53        Self::base(1, Backoff::fixed(Duration::ZERO))
54    }
55    /// Constant-delay retries (default 3 attempts).
56    pub fn fixed(delay: Duration) -> Self {
57        Self::base(3, Backoff::fixed(delay))
58    }
59    /// Exponential backoff (default 3 attempts, 100ms base, 10s max, no jitter).
60    pub fn exponential() -> Self {
61        Self::base(
62            3,
63            Backoff::exponential(Duration::from_millis(100), Duration::from_secs(10)),
64        )
65    }
66    /// Sensible default schedule: exponential + full jitter, 4 attempts.
67    /// Pair with `.when(..)` for transient-only classification (see spec §6).
68    pub fn standard() -> Self {
69        Self::base(
70            4,
71            Backoff::exponential(Duration::from_millis(100), Duration::from_secs(2)),
72        )
73        .jitter(Jitter::Full)
74    }
75
76    pub fn max_attempts(mut self, n: u32) -> Self {
77        self.max_attempts = n;
78        self
79    }
80    pub fn max_elapsed(mut self, d: Duration) -> Self {
81        self.max_elapsed = Some(d);
82        self
83    }
84    pub fn base_delay(mut self, d: Duration) -> Self {
85        self.backoff = match self.backoff {
86            Backoff::Exponential { max, .. } => Backoff::Exponential { base: d, max },
87            Backoff::Fixed(_) => Backoff::Fixed(d),
88        };
89        self
90    }
91    pub fn max_delay(mut self, d: Duration) -> Self {
92        if let Backoff::Exponential { base, .. } = self.backoff {
93            self.backoff = Backoff::Exponential { base, max: d };
94        }
95        self
96    }
97    pub fn jitter(mut self, j: Jitter) -> Self {
98        self.jitter = j;
99        self
100    }
101    /// Attach a shared [`RetryBudget`] to bound retries across calls.
102    pub fn budget(mut self, budget: RetryBudget) -> Self {
103        self.budget = Some(budget);
104        self
105    }
106    pub fn when(mut self, pred: impl Fn(&E) -> bool + Send + Sync + 'static) -> Self {
107        self.classifier = Classifier::WhenErr(Box::new(pred));
108        self
109    }
110    pub fn when_outcome(
111        mut self,
112        f: impl Fn(&Result<T, E>) -> RetryDecision + Send + Sync + 'static,
113    ) -> Self {
114        self.classifier = Classifier::WhenOutcome(Box::new(f));
115        self
116    }
117
118    pub(crate) fn max_attempts_value(&self) -> u32 {
119        self.max_attempts
120    }
121    pub(crate) fn max_elapsed_value(&self) -> Option<Duration> {
122        self.max_elapsed
123    }
124    pub(crate) fn decide(&self, outcome: &Result<T, E>) -> RetryDecision {
125        self.classifier.decide(outcome)
126    }
127    pub(crate) fn delay(&self, attempt: u32, core: &dyn Core) -> Duration {
128        let raw = self.backoff.raw_delay(attempt);
129        self.jitter.apply(raw, core.next_u64())
130    }
131    pub(crate) fn budget_ref(&self) -> Option<&RetryBudget> {
132        self.budget.as_ref()
133    }
134}
135
136#[cfg(test)]
137mod retry_tests {
138    use super::*;
139    use crate::core::{ManualClock, TestCore};
140
141    #[test]
142    fn presets_have_expected_attempt_caps() {
143        assert_eq!(Retry::<(), ()>::none().max_attempts_value(), 1);
144        assert_eq!(
145            Retry::<(), ()>::fixed(Duration::ZERO).max_attempts_value(),
146            3
147        );
148        assert_eq!(Retry::<(), ()>::exponential().max_attempts_value(), 3);
149        assert_eq!(Retry::<(), ()>::standard().max_attempts_value(), 4);
150    }
151
152    #[test]
153    fn builder_overrides_schedule() {
154        let r = Retry::<u32, &str>::exponential()
155            .max_attempts(5)
156            .base_delay(Duration::from_millis(20))
157            .max_delay(Duration::from_millis(80));
158        assert_eq!(r.max_attempts_value(), 5);
159        let core = TestCore::new(ManualClock::new());
160        assert_eq!(r.delay(1, &core), Duration::from_millis(20));
161        assert_eq!(r.delay(3, &core), Duration::from_millis(80));
162        assert_eq!(r.delay(9, &core), Duration::from_millis(80));
163    }
164
165    #[test]
166    fn when_predicate_controls_decision() {
167        let r = Retry::<u32, i32>::exponential().when(|e: &i32| *e >= 500);
168        assert_eq!(r.decide(&Err(503)), RetryDecision::Retry);
169        assert_eq!(r.decide(&Err(404)), RetryDecision::Stop);
170        assert_eq!(r.decide(&Ok(1)), RetryDecision::Stop);
171    }
172}