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}