Skip to main content

faucet_core/resilience/
execute.rs

1//! The retry runner: executes a fallible async op under a [`RetryPolicy`],
2//! honoring backoff, jitter, retry classification, and a cancellation token.
3
4use crate::error::FaucetError;
5use crate::resilience::classify::classify;
6use crate::resilience::policy::RetryPolicy;
7use std::future::Future;
8use tokio_util::sync::CancellationToken;
9
10/// Per-op labels for the metered runner. Identifies which pipeline / matrix row
11/// / I/O operation a retry belongs to so the resilience metrics
12/// (`faucet_resilience_retries_total` / `_retry_sleep_seconds` / `_giveup_total`)
13/// carry the spec's `{pipeline, row, op}` label set. `op` is a closed set
14/// (`sink_write` / `flush` / `state_put` / `source`).
15#[derive(Debug, Clone)]
16pub struct RetryMetrics {
17    /// Pipeline name label.
18    pub pipeline: String,
19    /// Matrix row id label (`""` for non-matrix runs).
20    pub row: String,
21    /// Operation label — a `&'static str` from the closed op set.
22    pub op: &'static str,
23}
24
25/// Execute `op` under `policy`. Retries retriable errors (per
26/// [`RetryPolicy::is_retriable`]) up to `max_attempts` total attempts, sleeping
27/// the backoff delay between attempts. If `cancel` is provided, a cancellation
28/// during a backoff sleep stops waiting and returns the last error immediately
29/// (the caller observes the token and flushes). `Ok` returns at once.
30///
31/// This is the connector-facing, label-free runner used by source connectors;
32/// the pipeline loop uses [`execute_with_policy_metered`] so the resilience
33/// metrics get their `{pipeline, row, op}` labels.
34pub async fn execute_with_policy<F, Fut, T>(
35    policy: &RetryPolicy,
36    cancel: Option<&CancellationToken>,
37    op: F,
38) -> Result<T, FaucetError>
39where
40    F: FnMut() -> Fut,
41    Fut: Future<Output = Result<T, FaucetError>>,
42{
43    run_with_policy(policy, cancel, None, op).await
44}
45
46/// Like [`execute_with_policy`], but emits the resilience metrics
47/// (`faucet_resilience_retries_total{op,class}`,
48/// `faucet_resilience_retry_sleep_seconds{op}`, and
49/// `faucet_resilience_giveup_total{op}` when retries are exhausted) using the
50/// labels in `metrics`. Behavior (retry/give-up/cancel outcomes) is identical to
51/// `execute_with_policy` — only the metric emission differs.
52pub async fn execute_with_policy_metered<F, Fut, T>(
53    policy: &RetryPolicy,
54    cancel: Option<&CancellationToken>,
55    metrics: &RetryMetrics,
56    op: F,
57) -> Result<T, FaucetError>
58where
59    F: FnMut() -> Fut,
60    Fut: Future<Output = Result<T, FaucetError>>,
61{
62    run_with_policy(policy, cancel, Some(metrics), op).await
63}
64
65/// Shared retry loop backing both the bare and metered public runners. When
66/// `metrics` is `Some`, emits per-attempt retry / sleep / give-up metrics; when
67/// `None`, the metric calls are skipped entirely (zero overhead on the
68/// connector-facing path).
69async fn run_with_policy<F, Fut, T>(
70    policy: &RetryPolicy,
71    cancel: Option<&CancellationToken>,
72    metrics: Option<&RetryMetrics>,
73    mut op: F,
74) -> Result<T, FaucetError>
75where
76    F: FnMut() -> Fut,
77    Fut: Future<Output = Result<T, FaucetError>>,
78{
79    // `max_attempts` is documented as "total attempts including the first
80    // (1 = no retry)". A caller-supplied `0` is meaningless — the loop always
81    // runs `op()` at least once before consulting the guard — so clamp it to 1
82    // rather than letting `attempt + 1 < 0` (which underflows to a large value
83    // and would retry) or `< 0` decide. Keeps the public `RetryPolicy` contract
84    // honest for library callers (F49).
85    let max_attempts = policy.max_attempts.max(1);
86    let mut attempt = 0u32;
87    loop {
88        match op().await {
89            Ok(val) => return Ok(val),
90            Err(e) if policy.is_retriable(&e) && attempt + 1 < max_attempts => {
91                let base = policy.backoff.delay(policy.base, policy.max, attempt);
92                let wait = if policy.jitter {
93                    crate::retry::apply_jitter(base)
94                } else {
95                    base
96                };
97                tracing::warn!(
98                    "operation failed (attempt {}/{}), retrying in {wait:?}: {e}",
99                    attempt + 1,
100                    max_attempts
101                );
102                if let Some(m) = metrics {
103                    // `is_retriable` above guarantees a class; fall back defensively.
104                    let class = classify(&e).map(|c| c.as_str()).unwrap_or("unknown");
105                    crate::observability::resilience::retry(&m.pipeline, &m.row, m.op, class);
106                    crate::observability::resilience::retry_sleep(
107                        &m.pipeline,
108                        &m.row,
109                        m.op,
110                        wait.as_secs_f64(),
111                    );
112                }
113                if !wait.is_zero() {
114                    match cancel {
115                        Some(token) => {
116                            tokio::select! {
117                                biased;
118                                _ = token.cancelled() => {
119                                    // Cancelled mid-backoff: the op was retriable but
120                                    // we are abandoning it — count as a give-up.
121                                    if let Some(m) = metrics {
122                                        crate::observability::resilience::giveup(
123                                            &m.pipeline, &m.row, m.op,
124                                        );
125                                    }
126                                    return Err(e);
127                                }
128                                _ = tokio::time::sleep(wait) => {}
129                            }
130                        }
131                        None => tokio::time::sleep(wait).await,
132                    }
133                }
134                attempt += 1;
135            }
136            Err(e) => {
137                // Retries exhausted (or a non-retriable error). Only count a
138                // give-up when at least one retry was attempted — a first-attempt
139                // non-retriable failure is not an exhausted budget.
140                if attempt > 0
141                    && let Some(m) = metrics
142                {
143                    crate::observability::resilience::giveup(&m.pipeline, &m.row, m.op);
144                }
145                return Err(e);
146            }
147        }
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::resilience::policy::BackoffKind;
155    use std::sync::Arc;
156    use std::sync::atomic::{AtomicU32, Ordering};
157    use std::time::Duration;
158
159    fn fast_policy(max_attempts: u32) -> RetryPolicy {
160        RetryPolicy {
161            max_attempts,
162            backoff: BackoffKind::None,
163            base: Duration::from_millis(0),
164            max: Duration::from_millis(0),
165            jitter: false,
166            ..RetryPolicy::default()
167        }
168    }
169
170    #[tokio::test]
171    async fn retries_then_succeeds() {
172        let calls = Arc::new(AtomicU32::new(0));
173        let c = calls.clone();
174        let r = execute_with_policy(&fast_policy(5), None, move || {
175            let n = c.fetch_add(1, Ordering::SeqCst);
176            async move {
177                if n < 2 {
178                    Err::<i32, _>(FaucetError::HttpStatus {
179                        status: 503,
180                        url: "u".into(),
181                        body: "".into(),
182                    })
183                } else {
184                    Ok(42)
185                }
186            }
187        })
188        .await;
189        assert_eq!(r.unwrap(), 42);
190        assert_eq!(calls.load(Ordering::SeqCst), 3);
191    }
192
193    #[tokio::test]
194    async fn gives_up_after_max_attempts() {
195        let calls = Arc::new(AtomicU32::new(0));
196        let c = calls.clone();
197        let r = execute_with_policy(&fast_policy(3), None, move || {
198            c.fetch_add(1, Ordering::SeqCst);
199            async {
200                Err::<i32, _>(FaucetError::HttpStatus {
201                    status: 503,
202                    url: "u".into(),
203                    body: "".into(),
204                })
205            }
206        })
207        .await;
208        assert!(r.is_err());
209        assert_eq!(calls.load(Ordering::SeqCst), 3, "1 initial + 2 retries");
210    }
211
212    #[tokio::test]
213    async fn max_attempts_zero_runs_op_exactly_once() {
214        // The public `RetryPolicy` contract documents `max_attempts` as "total
215        // attempts including the first (1 = no retry)". A misconfigured `0` is
216        // clamped to 1: the op runs exactly once and a retriable error is NOT
217        // retried (F49).
218        let calls = Arc::new(AtomicU32::new(0));
219        let c = calls.clone();
220        let r = execute_with_policy(&fast_policy(0), None, move || {
221            c.fetch_add(1, Ordering::SeqCst);
222            async {
223                Err::<i32, _>(FaucetError::HttpStatus {
224                    status: 503,
225                    url: "u".into(),
226                    body: "".into(),
227                })
228            }
229        })
230        .await;
231        assert!(r.is_err());
232        assert_eq!(
233            calls.load(Ordering::SeqCst),
234            1,
235            "max_attempts=0 clamps to 1: one execution, no retry"
236        );
237    }
238
239    #[tokio::test]
240    async fn non_retriable_fails_immediately() {
241        let calls = Arc::new(AtomicU32::new(0));
242        let c = calls.clone();
243        let r = execute_with_policy(&fast_policy(5), None, move || {
244            c.fetch_add(1, Ordering::SeqCst);
245            async { Err::<i32, _>(FaucetError::Auth("nope".into())) }
246        })
247        .await;
248        assert!(r.is_err());
249        assert_eq!(calls.load(Ordering::SeqCst), 1);
250    }
251
252    #[tokio::test]
253    async fn jittered_nonzero_backoff_with_no_cancel_retries() {
254        // Exercises the `jitter: true` + non-zero `Fixed` backoff + `None` cancel
255        // arms directly (the zero-backoff `fast_policy` skips both). Backoff is a
256        // few ms so the test stays fast while still sleeping a real interval.
257        let policy = RetryPolicy {
258            max_attempts: 3,
259            backoff: BackoffKind::Fixed,
260            base: Duration::from_millis(2),
261            max: Duration::from_millis(2),
262            jitter: true,
263            ..RetryPolicy::default()
264        };
265        let calls = Arc::new(AtomicU32::new(0));
266        let c = calls.clone();
267        let r = execute_with_policy(&policy, None, move || {
268            let n = c.fetch_add(1, Ordering::SeqCst);
269            async move {
270                if n < 1 {
271                    Err::<i32, _>(FaucetError::HttpStatus {
272                        status: 503,
273                        url: "u".into(),
274                        body: "".into(),
275                    })
276                } else {
277                    Ok(9)
278                }
279            }
280        })
281        .await;
282        assert_eq!(r.unwrap(), 9);
283        assert_eq!(calls.load(Ordering::SeqCst), 2);
284    }
285
286    #[tokio::test]
287    async fn cancel_during_backoff_returns_last_error_promptly() {
288        let policy = RetryPolicy {
289            max_attempts: 10,
290            backoff: BackoffKind::Fixed,
291            base: Duration::from_secs(30),
292            max: Duration::from_secs(30),
293            jitter: false,
294            ..RetryPolicy::default()
295        };
296        let token = CancellationToken::new();
297        token.cancel(); // already cancelled → backoff sleep returns at once
298        let r = execute_with_policy(&policy, Some(&token), || async {
299            Err::<i32, _>(FaucetError::HttpStatus {
300                status: 503,
301                url: "u".into(),
302                body: "".into(),
303            })
304        })
305        .await;
306        assert!(r.is_err());
307    }
308
309    fn metrics() -> RetryMetrics {
310        RetryMetrics {
311            pipeline: "p".into(),
312            row: "r".into(),
313            op: "sink_write",
314        }
315    }
316
317    // The metered runner must produce byte-for-byte identical retry / give-up /
318    // cancel outcomes to the bare runner — only the metric emission differs (the
319    // emits are no-ops without a recorder, so these assert behavior).
320
321    #[tokio::test]
322    async fn metered_retries_then_succeeds_same_as_bare() {
323        let calls = Arc::new(AtomicU32::new(0));
324        let c = calls.clone();
325        let m = metrics();
326        let r = execute_with_policy_metered(&fast_policy(5), None, &m, move || {
327            let n = c.fetch_add(1, Ordering::SeqCst);
328            async move {
329                if n < 2 {
330                    Err::<i32, _>(FaucetError::HttpStatus {
331                        status: 503,
332                        url: "u".into(),
333                        body: "".into(),
334                    })
335                } else {
336                    Ok(42)
337                }
338            }
339        })
340        .await;
341        assert_eq!(r.unwrap(), 42);
342        assert_eq!(calls.load(Ordering::SeqCst), 3);
343    }
344
345    #[tokio::test]
346    async fn metered_gives_up_after_max_attempts_same_as_bare() {
347        let calls = Arc::new(AtomicU32::new(0));
348        let c = calls.clone();
349        let m = metrics();
350        let r = execute_with_policy_metered(&fast_policy(3), None, &m, move || {
351            c.fetch_add(1, Ordering::SeqCst);
352            async {
353                Err::<i32, _>(FaucetError::HttpStatus {
354                    status: 503,
355                    url: "u".into(),
356                    body: "".into(),
357                })
358            }
359        })
360        .await;
361        assert!(r.is_err());
362        assert_eq!(calls.load(Ordering::SeqCst), 3, "1 initial + 2 retries");
363    }
364
365    #[tokio::test]
366    async fn metered_non_retriable_fails_immediately_same_as_bare() {
367        let calls = Arc::new(AtomicU32::new(0));
368        let c = calls.clone();
369        let m = metrics();
370        let r = execute_with_policy_metered(&fast_policy(5), None, &m, move || {
371            c.fetch_add(1, Ordering::SeqCst);
372            async { Err::<i32, _>(FaucetError::Auth("nope".into())) }
373        })
374        .await;
375        assert!(r.is_err());
376        assert_eq!(calls.load(Ordering::SeqCst), 1);
377    }
378
379    #[tokio::test]
380    async fn metered_cancel_during_backoff_returns_promptly() {
381        let policy = RetryPolicy {
382            max_attempts: 10,
383            backoff: BackoffKind::Fixed,
384            base: Duration::from_secs(30),
385            max: Duration::from_secs(30),
386            jitter: false,
387            ..RetryPolicy::default()
388        };
389        let token = CancellationToken::new();
390        token.cancel();
391        let m = metrics();
392        let r = execute_with_policy_metered(&policy, Some(&token), &m, || async {
393            Err::<i32, _>(FaucetError::HttpStatus {
394                status: 503,
395                url: "u".into(),
396                body: "".into(),
397            })
398        })
399        .await;
400        assert!(r.is_err());
401    }
402}