Skip to main content

dataflow_rs/engine/
retry.rs

1//! Retrying an operation that failed for a reason worth retrying.
2//!
3//! The crate has carried a complete retryability *classification* with no
4//! *mechanism*: [`DataflowError::retryable`] sorts every variant, and
5//! [`ErrorInfo`](crate::ErrorInfo) has `retry_attempted` / `retry_count` fields,
6//! but no engine code path acts on any of it. Every host has written the same
7//! loop.
8//!
9//! This module supplies the loop. It is deliberately **not** engine-level
10//! automatic retry: the engine cannot know which handlers are idempotent — an
11//! SMTP send that times out after `DATA` is indistinguishable from one that
12//! succeeded, and retrying duplicates the mail. Whether to retry stays a
13//! per-handler, per-call-site decision; the crate just supplies the correct
14//! loop for those that opt in.
15//!
16//! # Not available on wasm32
17//!
18//! Backoff needs a timer, and tokio's time driver does not run on
19//! `wasm32-unknown-unknown`. The whole module is `cfg`-gated off that target.
20
21use crate::engine::error::{DataflowError, Result};
22use std::future::Future;
23use std::time::Duration;
24use tokio::time::Instant;
25
26/// The longest a single backoff sleep may grow to, however many attempts have
27/// failed. Without a ceiling, doubling reaches minutes and a caller waiting on
28/// the result has no idea why.
29const MAX_BACKOFF: Duration = Duration::from_secs(60);
30
31/// How hard to retry, and for how long overall.
32///
33/// ```
34/// use dataflow_rs::RetryPolicy;
35/// use std::time::Duration;
36///
37/// // Three retries, 100ms doubling, but never more than 5s in total.
38/// let policy = RetryPolicy {
39///     max_retries: 3,
40///     retry_delay_ms: 100,
41///     deadline: Some(Duration::from_secs(5)),
42/// };
43/// assert_eq!(policy.max_retries, 3);
44/// ```
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct RetryPolicy {
47    /// Retries *after* the first attempt. `0` means try once and give up.
48    pub max_retries: u32,
49    /// Base delay. Doubles per attempt, capped at 60s.
50    pub retry_delay_ms: u64,
51    /// Wall-clock ceiling for the **whole** loop, sleeps included.
52    ///
53    /// Without this, a call with a 30s per-attempt timeout and capped backoff
54    /// can run to roughly 127s under a 30s caller budget — the per-attempt
55    /// bound says nothing about the total.
56    pub deadline: Option<Duration>,
57}
58
59impl Default for RetryPolicy {
60    /// Three retries, 100ms base delay, no deadline.
61    fn default() -> Self {
62        Self {
63            max_retries: 3,
64            retry_delay_ms: 100,
65            deadline: None,
66        }
67    }
68}
69
70impl RetryPolicy {
71    /// A policy that never retries. Useful as an explicit opt-out at a call
72    /// site that takes a policy.
73    pub fn none() -> Self {
74        Self {
75            max_retries: 0,
76            retry_delay_ms: 0,
77            deadline: None,
78        }
79    }
80
81    /// The backoff before retry number `retry` (1-based), capped.
82    fn backoff(&self, retry: u32) -> Duration {
83        let doubled = self
84            .retry_delay_ms
85            .checked_mul(1u64 << retry.min(32).saturating_sub(1))
86            .map_or(MAX_BACKOFF, Duration::from_millis);
87        doubled.min(MAX_BACKOFF)
88    }
89}
90
91/// Run `operation`, retrying while it fails retryably and budget remains.
92///
93/// Retries only when [`DataflowError::retryable`] says so — a validation error
94/// fails once and returns immediately, because trying it again cannot help.
95///
96/// ```no_run
97/// use dataflow_rs::{RetryPolicy, retry_with_policy, DataflowError, Result};
98/// # async fn demo() -> Result<String> {
99/// # async fn call_the_service() -> Result<String> { Ok(String::new()) }
100/// let body = retry_with_policy(RetryPolicy::default(), "user_service", || async {
101///     call_the_service().await
102/// })
103/// .await?;
104/// # Ok(body)
105/// # }
106/// ```
107///
108/// # Deadline
109///
110/// The deadline covers the whole loop, sleeps included. A backoff that would
111/// cross it is **skipped** rather than slept: sleeping only to fail afterwards
112/// spends latency the caller is already waiting on. The loop then ends with the
113/// last error.
114///
115/// Timing uses [`tokio::time::Instant`], so the deadline stays coherent with
116/// the sleeps under `tokio::time::pause()`.
117pub async fn retry_with_policy<T, F, Fut>(
118    policy: RetryPolicy,
119    label: &str,
120    operation: F,
121) -> Result<T>
122where
123    F: FnMut() -> Fut,
124    Fut: Future<Output = Result<T>>,
125{
126    retry_with_attempts(policy, label, operation).await.0
127}
128
129/// As [`retry_with_policy`], also reporting how many attempts were made.
130///
131/// The count is what fills [`ErrorInfo::retry_attempted`](crate::ErrorInfo) and
132/// `retry_count` — the two fields the crate has always carried with nothing to
133/// populate them:
134///
135/// ```no_run
136/// use dataflow_rs::{ErrorInfo, RetryPolicy, retry_with_attempts};
137/// # async fn demo() {
138/// # async fn call() -> dataflow_rs::Result<()> { Ok(()) }
139/// let (result, attempts) =
140///     retry_with_attempts(RetryPolicy::default(), "svc", || async { call().await }).await;
141///
142/// if let Err(err) = result {
143///     let mut info = ErrorInfo::simple_ref("SVC_FAILED", &err.to_string(), None);
144///     info.retry_attempted = Some(attempts > 1);
145///     info.retry_count = Some(attempts.saturating_sub(1));
146/// }
147/// # }
148/// ```
149pub async fn retry_with_attempts<T, F, Fut>(
150    policy: RetryPolicy,
151    label: &str,
152    mut operation: F,
153) -> (Result<T>, u32)
154where
155    F: FnMut() -> Fut,
156    Fut: Future<Output = Result<T>>,
157{
158    let started = Instant::now();
159    let mut attempts = 0u32;
160    let mut last: DataflowError;
161
162    loop {
163        attempts += 1;
164        match operation().await {
165            Ok(value) => return (Ok(value), attempts),
166            Err(err) => last = err,
167        }
168
169        if !last.retryable() {
170            log::debug!("{label}: not retryable after {attempts} attempt(s): {last}");
171            return (Err(last), attempts);
172        }
173
174        let retry = attempts; // the retry we are about to consider, 1-based
175        if retry > policy.max_retries {
176            log::debug!("{label}: giving up after {attempts} attempt(s): {last}");
177            return (Err(last), attempts);
178        }
179
180        let backoff = policy.backoff(retry);
181
182        // The deadline covers the sleep too. Sleeping and *then* failing spends
183        // latency the caller is already waiting on, so a backoff that would
184        // cross the line ends the loop instead.
185        if let Some(deadline) = policy.deadline {
186            let elapsed = started.elapsed();
187            if elapsed + backoff >= deadline {
188                log::debug!(
189                    "{label}: deadline {deadline:?} leaves no room for a {backoff:?} backoff \
190                     after {elapsed:?}; stopping at {attempts} attempt(s)"
191                );
192                return (Err(last), attempts);
193            }
194        }
195
196        log::debug!("{label}: retry {retry} in {backoff:?} after: {last}");
197        tokio::time::sleep(backoff).await;
198    }
199}