Skip to main content

async_nodes/
recovery.rs

1//! Per-node failure recovery: retry with backoff, or route to a declarative error edge.
2//!
3//! The engine owns the *mechanics* of recovery; consumers own the *classification*. The
4//! engine knows no HTTP statuses, no provider quirks — a consumer plugs its domain
5//! knowledge in through the `classify` hook of [`RecoveryPolicy::Retry`], mapping its
6//! errors (wrapped in [`BspError`]) to the generic [`Recovery`] lanes.
7//!
8//! # What retrying a node means
9//!
10//! **What the engine retries is the node execution, nothing larger.** Re-invoking
11//! `execute` re-runs the node's whole body with the same gathered inputs; whatever the
12//! failed attempt wrote into the consumer state or value store is the consumer's rollback
13//! concern. A node that wraps side effects (sending email, charging cards) must therefore
14//! opt into retry consciously: only read-only nodes, or nodes that implement
15//! exactly-once-ish rollback via [`crate::run::RunContext::execution_no`], should carry a
16//! retry policy. The type system cannot save you here — `RecoveryPolicy::Retry` on a node
17//! wrapping `send_email` sends email twice.
18
19use std::{
20    fmt,
21    sync::Arc,
22    time::{Duration, SystemTime, UNIX_EPOCH},
23};
24
25use serde::{Deserialize, Serialize};
26
27use super::error::BspError;
28
29/// How a failed node execution should be recovered, if at all.
30///
31/// Produced by the consumer's `classify` hook; consumed by the engine's retry loop.
32/// Deliberately generic: the engine knows no domain-specific failure lanes.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Recovery {
35    /// Transient failure — retry after backoff. `after` carries an external hint (e.g. a
36    /// server's `Retry-After` header captured by the consumer); the effective sleep is
37    /// `max(computed_backoff, after)`.
38    Retry { after: Option<Duration> },
39
40    /// Deterministic or unrecoverable failure. Propagate immediately; waiting cannot help.
41    Fatal,
42}
43
44/// Declarative backoff policy governing automatic retry of failed node executions.
45///
46/// Domain-free port of the shape battle-tested in the reference consumer (`llm-net`'s
47/// `graph/retry.rs`): exponential backoff from `base_delay` to a `max_delay` ceiling with
48/// symmetric jitter to prevent lockstep retries across parallel fan-outs.
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct RetryPolicy {
51    /// How many times a failed execution is re-attempted after the initial failure.
52    /// `0` disables retry entirely.
53    pub max_retries: u32,
54
55    /// The initial backoff delay, doubled per attempt.
56    pub base_delay: Duration,
57
58    /// The hard ceiling for any single backoff delay.
59    pub max_delay: Duration,
60
61    /// Symmetric randomization factor applied to each delay (e.g. `0.25` = ±25%).
62    pub jitter: f64,
63}
64
65impl Default for RetryPolicy {
66    /// The recommended default: 3 retries, 500ms → 8s exponential backoff, ±25% jitter.
67    fn default() -> Self {
68        Self {
69            max_retries: 3,
70            base_delay: Duration::from_millis(500),
71            max_delay: Duration::from_secs(8),
72            jitter: 0.25,
73        }
74    }
75}
76
77impl RetryPolicy {
78    /// A policy that never retries: the first failure propagates.
79    #[must_use]
80    pub const fn none() -> Self {
81        Self {
82            max_retries: 0,
83            base_delay: Duration::from_millis(500),
84            max_delay: Duration::from_secs(8),
85            jitter: 0.25,
86        }
87    }
88
89    /// Overrides the retry budget.
90    #[must_use]
91    pub const fn with_max_retries(mut self, max_retries: u32) -> Self {
92        self.max_retries = max_retries;
93        self
94    }
95
96    /// Overrides the initial backoff delay.
97    #[must_use]
98    pub const fn with_base_delay(mut self, base_delay: Duration) -> Self {
99        self.base_delay = base_delay;
100        self
101    }
102
103    /// Overrides the backoff ceiling.
104    #[must_use]
105    pub const fn with_max_delay(mut self, max_delay: Duration) -> Self {
106        self.max_delay = max_delay;
107        self
108    }
109
110    /// Overrides the jitter factor (`0.0` disables randomization).
111    #[must_use]
112    pub const fn with_jitter(mut self, jitter: f64) -> Self {
113        self.jitter = jitter;
114        self
115    }
116
117    /// Computes the backoff delay for the given (zero-based) retry attempt:
118    /// `min(base * 2^attempt, max) * (1 ± jitter)`.
119    #[must_use]
120    pub fn backoff(&self, attempt: u32) -> Duration {
121        // Clamp the shift: 2^6 already pushes any sane base past its ceiling.
122        let exp = self.base_delay.saturating_mul(1 << attempt.min(6));
123        let capped = exp.min(self.max_delay);
124
125        if self.jitter <= 0.0 {
126            return capped;
127        }
128        // Cheap time-derived entropy for jitter — keeps the dependency tree clean
129        // (no `rand` in the crate) and is more than uniform enough for backoff.
130        let nanos = SystemTime::now()
131            .duration_since(UNIX_EPOCH)
132            .map(|d| d.subsec_nanos())
133            .unwrap_or(0);
134        let r = f64::from(nanos % 1000) / 1000.0; // [0, 1)
135        let factor = (1.0 + self.jitter * (r * 2.0 - 1.0)).max(0.05);
136        capped.mul_f64(factor)
137    }
138}
139
140/// The consumer hook mapping a domain error (wrapped in [`BspError`]) to a recovery lane.
141pub type ClassifyFn = Arc<dyn Fn(&BspError) -> Recovery + Send + Sync>;
142
143/// Per-node recovery policy, declared by the node and enforced by the engine.
144///
145/// The default is [`RecoveryPolicy::FailFast`]: the first node error aborts the run, which
146/// is the behavior dataflow pipelines have always had.
147#[derive(Clone)]
148pub enum RecoveryPolicy {
149    /// First error aborts the run (the default).
150    FailFast,
151
152    /// Re-run the node with backoff when the classifier says the failure is transient.
153    ///
154    /// **Warning:** the engine re-invokes `execute` verbatim. Only attach this to nodes
155    /// that are read-only or that undo their partial writes on re-entry (see
156    /// [`crate::run::RunContext::execution_no`]); retrying a side-effecting node replays
157    /// the side effect.
158    Retry {
159        /// The backoff policy governing attempts.
160        policy: RetryPolicy,
161        /// Consumer hook classifying each failure into a [`Recovery`] lane.
162        classify: ClassifyFn,
163    },
164
165    /// Route the failure to declarative [`crate::condition::Condition::OnError`] edges
166    /// instead of aborting: the error is recorded in the run state (and value store) and
167    /// the run continues down the error path. GUI-renderable — error handling becomes
168    /// visible topology.
169    RouteToErrorEdge,
170}
171
172impl RecoveryPolicy {
173    /// Convenience constructor for [`RecoveryPolicy::Retry`] with a classification hook.
174    #[must_use]
175    pub fn retry(
176        policy: RetryPolicy,
177        classify: impl Fn(&BspError) -> Recovery + Send + Sync + 'static,
178    ) -> Self {
179        Self::Retry {
180            policy,
181            classify: Arc::new(classify),
182        }
183    }
184}
185
186impl fmt::Debug for RecoveryPolicy {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        match self {
189            Self::FailFast => f.write_str("FailFast"),
190            Self::Retry { policy, .. } => f.debug_tuple("Retry").field(policy).finish(),
191            Self::RouteToErrorEdge => f.write_str("RouteToErrorEdge"),
192        }
193    }
194}
195
196/// Async sleep used between retry attempts.
197///
198/// Uses `tokio::time::sleep` when any tokio-enabled crate feature is on. In tokio-free
199/// builds there is no runtime timer to hook into, so this falls back to blocking the
200/// current thread — correct but unkind to the surrounding executor; enable a tokio feature
201/// for production retry use.
202pub(crate) async fn sleep(duration: Duration) {
203    #[cfg(feature = "tokio")]
204    {
205        tokio::time::sleep(duration).await;
206    }
207    #[cfg(not(feature = "tokio"))]
208    {
209        std::thread::sleep(duration);
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_default_policy_is_three_retries() {
219        let policy = RetryPolicy::default();
220        assert_eq!(policy.max_retries, 3);
221        assert_eq!(policy.base_delay, Duration::from_millis(500));
222        assert_eq!(policy.max_delay, Duration::from_secs(8));
223        assert!((policy.jitter - 0.25).abs() < f64::EPSILON);
224    }
225
226    #[test]
227    fn test_none_disables_retry() {
228        assert_eq!(RetryPolicy::none().max_retries, 0);
229    }
230
231    #[test]
232    fn test_const_builders() {
233        let policy = RetryPolicy::default()
234            .with_max_retries(5)
235            .with_base_delay(Duration::from_millis(10))
236            .with_max_delay(Duration::from_secs(1))
237            .with_jitter(0.0);
238        assert_eq!(policy.max_retries, 5);
239        assert_eq!(policy.base_delay, Duration::from_millis(10));
240        assert_eq!(policy.max_delay, Duration::from_secs(1));
241        assert!(policy.jitter.abs() < f64::EPSILON);
242    }
243
244    #[test]
245    fn test_backoff_grows_and_respects_bounds() {
246        let policy = RetryPolicy::default().with_jitter(0.0);
247        assert_eq!(policy.backoff(0), Duration::from_millis(500));
248        assert_eq!(policy.backoff(1), Duration::from_secs(1));
249        assert_eq!(policy.backoff(2), Duration::from_secs(2));
250        // Past the ceiling: capped, never panics on huge attempts.
251        assert_eq!(policy.backoff(100), Duration::from_secs(8));
252    }
253
254    #[test]
255    fn test_backoff_jitter_stays_within_factor() {
256        let policy = RetryPolicy::default().with_jitter(0.25);
257        for attempt in 0..10 {
258            let d = policy.backoff(attempt);
259            let base = policy.clone().with_jitter(0.0).backoff(attempt);
260            let lo = base.mul_f64(0.74); // 0.75 minus float slack
261            let hi = base.mul_f64(1.26);
262            assert!(
263                d >= lo && d <= hi,
264                "attempt {attempt}: {d:?} outside [{lo:?}, {hi:?}]"
265            );
266        }
267    }
268
269    #[test]
270    fn test_recovery_policy_retry_constructor() {
271        let policy = RecoveryPolicy::retry(RetryPolicy::none(), |_| Recovery::Fatal);
272        let RecoveryPolicy::Retry { classify, .. } = policy else {
273            panic!("expected Retry variant");
274        };
275        assert_eq!(classify(&BspError::Deadlock), Recovery::Fatal);
276    }
277}