Skip to main content

pe_graph/
retry.rs

1//! Retry policy -- per-phase retry with exponential backoff and jitter.
2//!
3//! Used by the `node!` DSL `#[retry]` annotation and directly by users
4//! who want retry semantics on fallible async operations within nodes.
5
6use pe_core::node::NodeResult;
7use pe_core::state::StateUpdate;
8use serde::{Deserialize, Serialize};
9use std::future::Future;
10use std::time::Duration;
11
12/// Configuration for retrying a fallible operation with backoff.
13///
14/// # Example
15///
16/// ```ignore
17/// use pe_graph::retry::{RetryPolicy, with_retry};
18///
19/// let result = with_retry(&RetryPolicy::default(), || {
20///     Box::pin(async { call_llm().await })
21/// }).await;
22/// ```
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct RetryPolicy {
25    /// Maximum number of retry attempts (not counting the initial attempt).
26    pub max_attempts: u32,
27    /// Delay before the first retry.
28    pub initial_interval: Duration,
29    /// Multiplier applied to the delay after each retry.
30    pub backoff_factor: f64,
31    /// Maximum delay between retries (caps exponential growth).
32    pub max_interval: Duration,
33    /// Whether to add random jitter to delays (prevents thundering herd).
34    pub jitter: bool,
35}
36
37impl Default for RetryPolicy {
38    fn default() -> Self {
39        Self {
40            max_attempts: 3,
41            initial_interval: Duration::from_millis(200),
42            backoff_factor: 2.0,
43            max_interval: Duration::from_secs(10),
44            jitter: true,
45        }
46    }
47}
48
49/// Execute an async operation with retry semantics.
50///
51/// Retries only on [`PeError::is_retryable`](pe_core::PeError::is_retryable) errors. Non-retryable errors
52/// and all non-error results (`Update`, `Interrupt`, `Converge`) are
53/// returned immediately.
54///
55/// The closure `f` is called for each attempt. It must return a pinned future.
56///
57/// # Example
58///
59/// ```ignore
60/// let result = with_retry(&RetryPolicy::default(), || {
61///     Box::pin(async { do_work().await })
62/// }).await;
63/// ```
64pub async fn with_retry<F, Fut, U>(policy: &RetryPolicy, f: F) -> NodeResult<U>
65where
66    F: Fn() -> Fut,
67    Fut: Future<Output = NodeResult<U>>,
68    U: StateUpdate,
69{
70    let mut attempts = 0u32;
71    let mut delay = policy.initial_interval;
72
73    loop {
74        let result = f().await;
75
76        match &result {
77            NodeResult::Error(e) if e.is_retryable() && attempts < policy.max_attempts => {
78                attempts += 1;
79                let sleep_dur = if policy.jitter {
80                    apply_jitter(delay)
81                } else {
82                    delay
83                };
84                tokio::time::sleep(sleep_dur).await;
85                delay = next_delay(delay, policy.backoff_factor, policy.max_interval);
86            }
87            _ => return result,
88        }
89    }
90}
91
92/// Compute the next backoff delay, capped at max_interval.
93pub(crate) fn next_delay(current: Duration, factor: f64, max: Duration) -> Duration {
94    let next = current.mul_f64(factor);
95    if next > max { max } else { next }
96}
97
98/// Apply jitter: random value in [50%, 150%) of the delay.
99pub(crate) fn apply_jitter(delay: Duration) -> Duration {
100    let nanos = delay.as_nanos() as u64;
101    let jitter_nanos = pseudo_random_u64(nanos.max(1));
102    let half = nanos / 2;
103    Duration::from_nanos(half + jitter_nanos)
104}
105
106/// Pseudo-random u64 in [0, range) using `RandomState` entropy.
107/// Not cryptographic -- only used for jitter timing.
108/// Uses `RandomState` instead of `SystemTime` to avoid identical
109/// values when called at the same nanosecond.
110fn pseudo_random_u64(range: u64) -> u64 {
111    if range == 0 {
112        return 0;
113    }
114    use std::collections::hash_map::RandomState;
115    use std::hash::{BuildHasher, Hasher};
116    let mut hasher = RandomState::new().build_hasher();
117    hasher.write_u64(range);
118    hasher.finish() % range
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use pe_core::error::PeError;
125    use pe_core::node::NodeResult;
126    use std::sync::atomic::{AtomicU32, Ordering};
127
128    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
129    struct DummyUpdate;
130    impl pe_core::state::StateUpdate for DummyUpdate {}
131
132    #[tokio::test]
133    async fn test_succeeds_on_first_try() {
134        let policy = RetryPolicy {
135            max_attempts: 3,
136            jitter: false,
137            ..Default::default()
138        };
139        let result = with_retry(&policy, || async {
140            NodeResult::<DummyUpdate>::Update(DummyUpdate)
141        })
142        .await;
143        assert!(matches!(result, NodeResult::Update(_)));
144    }
145
146    #[tokio::test]
147    async fn test_retries_then_succeeds() {
148        let attempts = AtomicU32::new(0);
149        let policy = RetryPolicy {
150            max_attempts: 3,
151            initial_interval: Duration::from_millis(1),
152            jitter: false,
153            ..Default::default()
154        };
155
156        let result = with_retry(&policy, || {
157            let count = attempts.fetch_add(1, Ordering::SeqCst);
158            async move {
159                if count < 2 {
160                    NodeResult::<DummyUpdate>::Error(PeError::Timeout { seconds: 1.0 })
161                } else {
162                    NodeResult::Update(DummyUpdate)
163                }
164            }
165        })
166        .await;
167
168        assert!(matches!(result, NodeResult::Update(_)));
169        assert_eq!(attempts.load(Ordering::SeqCst), 3); // initial + 2 retries
170    }
171
172    #[tokio::test]
173    async fn test_exhausts_retries_returns_error() {
174        let attempts = AtomicU32::new(0);
175        let policy = RetryPolicy {
176            max_attempts: 2,
177            initial_interval: Duration::from_millis(1),
178            jitter: false,
179            ..Default::default()
180        };
181
182        let result = with_retry(&policy, || {
183            attempts.fetch_add(1, Ordering::SeqCst);
184            async { NodeResult::<DummyUpdate>::Error(PeError::Timeout { seconds: 1.0 }) }
185        })
186        .await;
187
188        assert!(matches!(result, NodeResult::Error(PeError::Timeout { .. })));
189        // 1 initial + 2 retries = 3 total attempts
190        assert_eq!(attempts.load(Ordering::SeqCst), 3);
191    }
192
193    #[tokio::test]
194    async fn test_non_retryable_error_not_retried() {
195        let attempts = AtomicU32::new(0);
196        let policy = RetryPolicy {
197            max_attempts: 3,
198            initial_interval: Duration::from_millis(1),
199            jitter: false,
200            ..Default::default()
201        };
202
203        let result = with_retry(&policy, || {
204            attempts.fetch_add(1, Ordering::SeqCst);
205            async {
206                NodeResult::<DummyUpdate>::Error(PeError::PermissionDenied {
207                    action: "test".into(),
208                })
209            }
210        })
211        .await;
212
213        assert!(matches!(
214            result,
215            NodeResult::Error(PeError::PermissionDenied { .. })
216        ));
217        assert_eq!(attempts.load(Ordering::SeqCst), 1); // no retries
218    }
219
220    #[tokio::test]
221    async fn test_interrupt_not_retried() {
222        let policy = RetryPolicy {
223            max_attempts: 3,
224            jitter: false,
225            ..Default::default()
226        };
227
228        let result = with_retry(&policy, || async {
229            NodeResult::<DummyUpdate>::Interrupt(pe_core::node::InterruptRequest {
230                reason: "test".into(),
231                partial_update: None,
232                resume_point: "test:0".into(),
233            })
234        })
235        .await;
236
237        assert!(matches!(result, NodeResult::Interrupt(_)));
238    }
239
240    #[test]
241    fn test_next_delay_exponential() {
242        let d = next_delay(Duration::from_millis(100), 2.0, Duration::from_secs(10));
243        assert_eq!(d, Duration::from_millis(200));
244    }
245
246    #[test]
247    fn test_next_delay_capped_at_max() {
248        let d = next_delay(Duration::from_secs(8), 2.0, Duration::from_secs(10));
249        assert_eq!(d, Duration::from_secs(10));
250    }
251}