Skip to main content

adk_graph/
retry.rs

1//! Per-node retry with exponential backoff.
2//!
3//! A node that fails aborts the run. That is right for a logic error and wrong
4//! for a call to a service that is briefly unavailable, so a policy can be
5//! attached per node to try again with a growing delay.
6//!
7//! Retry is off unless configured: a node with no policy runs once. A policy
8//! from [`RetryPolicy::default`] allows ten attempts,
9//! so a graph that sets no policy behaves exactly as before.
10//!
11//! # Example
12//!
13//! ```rust
14//! use adk_graph::retry::{RetryOn, RetryPolicy};
15//! use std::time::Duration;
16//!
17//! let policy = RetryPolicy::new(4)
18//!     .with_initial_delay(Duration::from_millis(200))
19//!     .with_max_delay(Duration::from_secs(5))
20//!     .with_backoff_factor(2.0)
21//!     .with_jitter(0.0)
22//!     .with_retry_on(RetryOn::Any);
23//!
24//! // Attempt 1 fails, then 200ms, 400ms, 800ms.
25//! assert_eq!(policy.delay_for_attempt(1), Duration::from_millis(200));
26//! assert_eq!(policy.delay_for_attempt(2), Duration::from_millis(400));
27//! assert_eq!(policy.delay_for_attempt(3), Duration::from_millis(800));
28//! ```
29
30use std::sync::Arc;
31use std::time::Duration;
32
33use crate::error::GraphError;
34
35/// Which failures a policy retries.
36#[derive(Clone)]
37pub enum RetryOn {
38    /// Every failure except an interrupt.
39    ///
40    /// An interrupt is control flow, not an error: it means a person or a node
41    /// asked the run to pause. Retrying it would spin. `Any` therefore excludes
42    /// it, which is worth stating because the name reads as though it would not.
43    Any,
44    /// Only a node timeout.
45    Timeout,
46    /// A caller-supplied predicate.
47    Custom(Arc<dyn Fn(&GraphError) -> bool + Send + Sync>),
48}
49
50impl std::fmt::Debug for RetryOn {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            Self::Any => f.write_str("Any"),
54            Self::Timeout => f.write_str("Timeout"),
55            Self::Custom(_) => f.write_str("Custom(..)"),
56        }
57    }
58}
59
60impl RetryOn {
61    /// Whether this error should be retried.
62    pub fn should_retry(&self, error: &GraphError) -> bool {
63        // Never retry an interrupt, whatever the policy says: a pause that
64        // retried would defeat the pause.
65        if matches!(error, GraphError::Interrupted(_)) {
66            return false;
67        }
68        match self {
69            Self::Any => true,
70            Self::Timeout => matches!(error, GraphError::NodeTimedOut { .. }),
71            Self::Custom(predicate) => predicate(error),
72        }
73    }
74}
75
76/// How many times a node is attempted, and how long between attempts.
77#[derive(Debug, Clone)]
78pub struct RetryPolicy {
79    /// Total attempts, including the first. `1` means no retry.
80    pub max_attempts: u32,
81    /// Delay before the second attempt.
82    pub initial_delay: Duration,
83    /// Ceiling on any single delay.
84    pub max_delay: Duration,
85    /// Multiplier applied to the delay after each attempt.
86    pub backoff_factor: f64,
87    /// Fraction of the delay to vary randomly, so retries from many nodes do not
88    /// align. `0.0` is exact, `1.0` varies the delay across its whole width.
89    pub jitter: f64,
90    /// Which failures to retry.
91    pub retry_on: RetryOn,
92}
93
94/// Ten attempts, one second of initial delay, doubling to a sixty-second cap.
95///
96/// The nine sleeps between ten attempts total about 243 seconds, so a node that
97/// keeps failing takes roughly four minutes to give up, plus the time each attempt
98/// itself takes. Lower `max_attempts` where a caller is waiting.
99///
100/// Attaching no policy at all is still one attempt: this default applies only to a
101/// policy you construct.
102impl Default for RetryPolicy {
103    fn default() -> Self {
104        Self {
105            max_attempts: 10,
106            initial_delay: Duration::from_secs(1),
107            max_delay: Duration::from_secs(60),
108            backoff_factor: 2.0,
109            jitter: 1.0,
110            retry_on: RetryOn::Any,
111        }
112    }
113}
114
115impl RetryPolicy {
116    /// A policy allowing `max_attempts` in total, with the other defaults.
117    pub fn new(max_attempts: u32) -> Self {
118        Self { max_attempts: max_attempts.max(1), ..Default::default() }
119    }
120
121    /// Set the delay before the second attempt.
122    pub fn with_initial_delay(mut self, delay: Duration) -> Self {
123        self.initial_delay = delay;
124        self
125    }
126
127    /// Set the ceiling on any single delay.
128    pub fn with_max_delay(mut self, delay: Duration) -> Self {
129        self.max_delay = delay;
130        self
131    }
132
133    /// Set the multiplier applied after each attempt.
134    pub fn with_backoff_factor(mut self, factor: f64) -> Self {
135        self.backoff_factor = factor;
136        self
137    }
138
139    /// Set how much the delay varies randomly, as a fraction of itself.
140    pub fn with_jitter(mut self, jitter: f64) -> Self {
141        self.jitter = jitter.clamp(0.0, 1.0);
142        self
143    }
144
145    /// Set which failures to retry.
146    pub fn with_retry_on(mut self, retry_on: RetryOn) -> Self {
147        self.retry_on = retry_on;
148        self
149    }
150
151    /// Whether another attempt is allowed after `attempts_so_far` failures.
152    pub fn allows_another_attempt(&self, attempts_so_far: u32) -> bool {
153        attempts_so_far < self.max_attempts
154    }
155
156    /// The delay before attempt number `attempt`, counting the first attempt as
157    /// zero, so `delay_for_attempt(1)` precedes the second attempt.
158    ///
159    /// The delay grows by `backoff_factor` each time, is clamped to `max_delay`,
160    /// and is then varied by up to `jitter` of itself. A `backoff_factor` at or
161    /// below zero is treated as `1.0`, giving a constant delay rather than
162    /// collapsing to nothing.
163    ///
164    /// The clamp happens before the jitter, so a jittered delay can sit slightly
165    /// above `max_delay` only when `jitter` is positive; with `jitter` at zero the
166    /// ceiling is exact.
167    pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
168        if attempt == 0 {
169            return Duration::ZERO;
170        }
171        let factor = if self.backoff_factor <= 0.0 { 1.0 } else { self.backoff_factor };
172        let mut millis = self.initial_delay.as_millis() as f64;
173        for _ in 1..attempt {
174            millis *= factor;
175        }
176        let capped = millis.min(self.max_delay.as_millis() as f64);
177
178        if self.jitter <= 0.0 {
179            return Duration::from_millis(capped as u64);
180        }
181        // Vary symmetrically around the capped delay, floored at zero.
182        let spread = capped * self.jitter;
183        let offset = pseudo_random_unit() * 2.0 * spread - spread;
184        Duration::from_millis((capped + offset).max(0.0) as u64)
185    }
186}
187
188/// A value in `[0, 1)` derived from the clock.
189///
190/// Jitter only has to spread retries apart; it is not a security primitive, so
191/// this avoids taking a dependency on a random number generator.
192fn pseudo_random_unit() -> f64 {
193    use std::time::{SystemTime, UNIX_EPOCH};
194    let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0);
195    f64::from(nanos % 1_000_000) / 1_000_000.0
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    /// A constructed default retries; attaching no policy at all does not. The
203    /// second half of that is the executor's business, not this type's.
204    #[test]
205    fn a_default_policy_retries_ten_times() {
206        let policy = RetryPolicy::default();
207        assert_eq!(policy.max_attempts, 10);
208        assert!(policy.allows_another_attempt(1), "a first failure is retried");
209        assert!(policy.allows_another_attempt(9), "and so is a ninth");
210        assert!(!policy.allows_another_attempt(10), "the tenth is the last");
211    }
212
213    /// The nine sleeps between ten attempts, so the wall-clock cost is stated in
214    /// one place rather than inferred from the backoff rules.
215    #[test]
216    fn the_default_gives_up_after_about_four_minutes() {
217        let policy = RetryPolicy::default().with_jitter(0.0);
218        let total: Duration = (1..policy.max_attempts).map(|n| policy.delay_for_attempt(n)).sum();
219        assert_eq!(total, Duration::from_secs(243));
220    }
221
222    #[test]
223    fn delays_grow_by_the_backoff_factor() {
224        let policy = RetryPolicy::new(5)
225            .with_initial_delay(Duration::from_millis(100))
226            .with_backoff_factor(3.0)
227            .with_jitter(0.0);
228        assert_eq!(policy.delay_for_attempt(1), Duration::from_millis(100));
229        assert_eq!(policy.delay_for_attempt(2), Duration::from_millis(300));
230        assert_eq!(policy.delay_for_attempt(3), Duration::from_millis(900));
231    }
232
233    #[test]
234    fn a_delay_is_capped() {
235        let policy = RetryPolicy::new(10)
236            .with_initial_delay(Duration::from_millis(100))
237            .with_max_delay(Duration::from_millis(250))
238            .with_backoff_factor(10.0)
239            .with_jitter(0.0);
240        assert_eq!(policy.delay_for_attempt(3), Duration::from_millis(250));
241    }
242
243    #[test]
244    fn a_non_positive_backoff_factor_gives_a_constant_delay() {
245        let policy = RetryPolicy::new(4)
246            .with_initial_delay(Duration::from_millis(50))
247            .with_backoff_factor(0.0)
248            .with_jitter(0.0);
249        assert_eq!(policy.delay_for_attempt(1), Duration::from_millis(50));
250        assert_eq!(policy.delay_for_attempt(3), Duration::from_millis(50));
251    }
252
253    #[test]
254    fn an_interrupt_is_never_retried() {
255        let interrupt = GraphError::Interrupted(Box::new(crate::error::InterruptedExecution::new(
256            "t".to_string(),
257            "c".to_string(),
258            crate::interrupt::Interrupt::Before("n".to_string()),
259            Default::default(),
260            0,
261        )));
262        assert!(!RetryOn::Any.should_retry(&interrupt));
263        let always = RetryOn::Custom(Arc::new(|_| true));
264        assert!(!always.should_retry(&interrupt), "even a permissive predicate must not retry it");
265    }
266
267    #[test]
268    fn timeout_only_retries_a_timeout() {
269        let timeout =
270            GraphError::NodeTimedOut { node: "slow".to_string(), elapsed: Duration::from_secs(1) };
271        let other =
272            GraphError::NodeExecutionFailed { node: "n".to_string(), message: "boom".to_string() };
273        assert!(RetryOn::Timeout.should_retry(&timeout));
274        assert!(!RetryOn::Timeout.should_retry(&other));
275        assert!(RetryOn::Any.should_retry(&other));
276    }
277}