Skip to main content

canton_core/
retry.rs

1//! Opt-in retry with exponential backoff.
2//!
3//! Retries fire only on [`crate::Error::is_retriable`] errors, up to a bounded
4//! number of attempts. Callers build the request once (so a retried command
5//! keeps the same `command_id` and stays de-duplication-safe) and pass an
6//! operation that re-runs the RPC.
7
8use std::future::Future;
9use std::time::Duration;
10
11use crate::Result;
12
13/// Retry policy for unary calls.
14///
15/// Start from [`RetryConfig::default`] and adjust with the fluent setters;
16/// `#[non_exhaustive]` so fields can be added without a breaking change.
17#[derive(Clone, Debug)]
18#[non_exhaustive]
19pub struct RetryConfig {
20    /// Maximum number of attempts (including the first). `1` disables retrying.
21    pub max_attempts: u32,
22    /// Backoff before the first retry.
23    pub initial_backoff: Duration,
24    /// Upper bound the backoff doubles towards.
25    pub max_backoff: Duration,
26    /// Optional per-attempt timeout. When set, an attempt that exceeds it is
27    /// cancelled and treated as a retriable timeout (bounding a hung call
28    /// independently of the channel-level timeout).
29    pub attempt_timeout: Option<Duration>,
30}
31
32impl Default for RetryConfig {
33    fn default() -> Self {
34        Self {
35            max_attempts: 3,
36            initial_backoff: Duration::from_millis(100),
37            max_backoff: Duration::from_secs(5),
38            attempt_timeout: None,
39        }
40    }
41}
42
43impl RetryConfig {
44    /// Set the maximum number of attempts (including the first).
45    #[must_use]
46    pub fn with_max_attempts(mut self, max_attempts: u32) -> Self {
47        self.max_attempts = max_attempts;
48        self
49    }
50
51    /// Set the initial backoff before the first retry.
52    #[must_use]
53    pub fn with_initial_backoff(mut self, initial_backoff: Duration) -> Self {
54        self.initial_backoff = initial_backoff;
55        self
56    }
57
58    /// Set the ceiling the backoff doubles towards.
59    #[must_use]
60    pub fn with_max_backoff(mut self, max_backoff: Duration) -> Self {
61        self.max_backoff = max_backoff;
62        self
63    }
64
65    /// Set a per-attempt timeout: an attempt exceeding it is cancelled and
66    /// retried (as a timeout) rather than blocking on the channel-level timeout.
67    #[must_use]
68    pub fn with_attempt_timeout(mut self, attempt_timeout: Duration) -> Self {
69        self.attempt_timeout = Some(attempt_timeout);
70        self
71    }
72}
73
74/// Run `op`, retrying on retriable errors per `config`. With no `config`, runs
75/// `op` exactly once.
76///
77/// # Errors
78/// Returns the last error from `op` once attempts are exhausted or a
79/// non-retriable error is hit.
80pub async fn run_with_retry<T, F, Fut>(config: Option<&RetryConfig>, mut op: F) -> Result<T>
81where
82    F: FnMut() -> Fut,
83    Fut: Future<Output = Result<T>>,
84{
85    let Some(config) = config else {
86        return op().await;
87    };
88
89    let mut attempt = 1u32;
90    let mut backoff = config.initial_backoff;
91    loop {
92        // Bound the attempt if a per-attempt timeout is configured; a timeout is
93        // a retriable outcome (the op is retried like any transient failure).
94        let outcome = match config.attempt_timeout {
95            Some(timeout) => match tokio::time::timeout(timeout, op()).await {
96                Ok(result) => result,
97                Err(_) => Err(crate::Error::Timeout),
98            },
99            None => op().await,
100        };
101        match outcome {
102            Ok(value) => return Ok(value),
103            Err(err) if err.is_retriable() && attempt < config.max_attempts => {
104                // Canton attaches a `RetryInfo` recommendation to retryable
105                // errors; when the server asks for a longer pause than the
106                // local schedule, honour it (it knows why it rejected us).
107                //
108                // Jitter then spreads the herd — but only upwards. A server
109                // recommendation is a *minimum*: `RESOURCE_EXHAUSTED` with
110                // "retry in 3s" means the participant will refuse again before
111                // then, so coming back at 1.5s spends an attempt on a rejection
112                // that was guaranteed. Without a recommendation the local
113                // backoff is ours to scatter in either direction.
114                let recommended = err.retry_delay();
115                let delay = recommended.map_or(backoff, |server| server.max(backoff));
116                let delay = match recommended {
117                    Some(minimum) => with_jitter(delay).max(minimum),
118                    None => with_jitter(delay),
119                };
120                tokio::time::sleep(delay).await;
121                backoff = (backoff * 2).min(config.max_backoff);
122                attempt += 1;
123            }
124            Err(err) => return Err(err),
125        }
126    }
127}
128
129/// Equal jitter (×0.5–1.5) on a backoff delay, so retries from many clients
130/// failing at once do not re-arrive in lockstep (thundering herd). Uses the
131/// clock's sub-second nanos as a cheap entropy source — no RNG dependency.
132fn with_jitter(backoff: Duration) -> Duration {
133    let nanos = std::time::SystemTime::now()
134        .duration_since(std::time::UNIX_EPOCH)
135        .map_or(0, |d| d.subsec_nanos());
136    let factor = 0.5 + f64::from(nanos % 1024) / 1024.0;
137    backoff.mul_f64(factor)
138}
139
140#[cfg(test)]
141#[allow(clippy::unwrap_used)]
142mod tests {
143    use super::*;
144    use crate::Error;
145    use std::cell::Cell;
146
147    fn fast() -> RetryConfig {
148        RetryConfig::default()
149            .with_initial_backoff(Duration::from_millis(1))
150            .with_max_backoff(Duration::from_millis(1))
151    }
152
153    #[tokio::test]
154    async fn retries_retriable_errors_then_succeeds() {
155        let calls = Cell::new(0);
156        let result: Result<u32> = run_with_retry(Some(&fast()), || {
157            calls.set(calls.get() + 1);
158            let n = calls.get();
159            async move { if n < 3 { Err(Error::Timeout) } else { Ok(n) } }
160        })
161        .await;
162
163        assert_eq!(result.unwrap(), 3);
164        assert_eq!(calls.get(), 3);
165    }
166
167    #[tokio::test(start_paused = true)]
168    async fn the_backoff_doubles_each_attempt_up_to_the_cap() {
169        // A schedule with room to grow: 10ms initial, 80ms cap, 5 attempts.
170        // Expected sleeps between attempts, before jitter: 10, 20, 40, 80, 80
171        // (doubling, then clamped). Jitter scales 0.5-1.5, so assert each
172        // observed gap lands in that band around the expected pre-jitter value
173        // - which pins the doubling and the cap, the two things a `* -> /` or a
174        // missing `.min()` would break.
175        let cfg = RetryConfig::default()
176            .with_max_attempts(6)
177            .with_initial_backoff(Duration::from_millis(10))
178            .with_max_backoff(Duration::from_millis(80));
179
180        let starts = std::cell::RefCell::new(Vec::new());
181        let calls = Cell::new(0u32);
182        let _: Result<u32> = run_with_retry(Some(&cfg), || {
183            starts.borrow_mut().push(tokio::time::Instant::now());
184            calls.set(calls.get() + 1);
185            async move { Err::<u32, _>(Error::Timeout) }
186        })
187        .await;
188
189        let t = starts.borrow();
190        let gaps: Vec<u64> = t
191            .windows(2)
192            .map(|w| u64::try_from((w[1] - w[0]).as_millis()).unwrap_or(u64::MAX))
193            .collect();
194        let expected = [10u64, 20, 40, 80, 80];
195        assert_eq!(gaps.len(), expected.len(), "one gap per retry: {gaps:?}");
196        for (g, e) in gaps.iter().zip(expected) {
197            // Jitter scales 0.5-1.5; keep the check in integer arithmetic to
198            // pin the doubling and the cap without a float cast.
199            assert!(
200                *g * 2 >= e && *g * 2 <= e * 3,
201                "gap {g}ms outside jitter band of expected {e}ms; gaps={gaps:?}"
202            );
203        }
204    }
205
206    #[tokio::test(start_paused = true)]
207    async fn it_stops_after_max_attempts_rather_than_looping() {
208        // The mutation pass flagged loop-termination mutants as *hangs*, not
209        // clean failures - nothing bounded the attempt count in time. This
210        // asserts the exact number of calls, so a broken stop fails loudly
211        // instead of spinning until the harness kills it.
212        let cfg = RetryConfig::default()
213            .with_max_attempts(4)
214            .with_initial_backoff(Duration::from_millis(1))
215            .with_max_backoff(Duration::from_millis(1));
216        let calls = Cell::new(0u32);
217        let result: Result<u32> = run_with_retry(Some(&cfg), || {
218            calls.set(calls.get() + 1);
219            async move { Err::<u32, _>(Error::Timeout) }
220        })
221        .await;
222        assert!(result.is_err());
223        assert_eq!(
224            calls.get(),
225            4,
226            "exactly max_attempts calls, no more, no fewer"
227        );
228    }
229
230    #[tokio::test(start_paused = true)]
231    async fn a_server_recommended_delay_stretches_the_backoff() {
232        // The op fails once with a Canton-style status carrying
233        // `RetryInfo { retry_delay: 3s }`, far above the local 1ms schedule.
234        // With the paused clock, the elapsed time measures the actual sleep.
235        let delay = Duration::from_secs(3);
236        let status = {
237            use tonic_types::{ErrorDetails, StatusExt as _};
238            let mut details = ErrorDetails::new();
239            details.set_retry_info(Some(delay));
240            tonic::Status::with_error_details(tonic::Code::Unavailable, "wait", details)
241        };
242
243        let started = tokio::time::Instant::now();
244        let calls = Cell::new(0);
245        let result: Result<u32> = run_with_retry(Some(&fast()), || {
246            calls.set(calls.get() + 1);
247            let n = calls.get();
248            let err = Error::from(status.clone());
249            async move { if n == 1 { Err(err) } else { Ok(n) } }
250        })
251        .await;
252
253        assert_eq!(result.unwrap(), 2);
254        // The recommendation is a floor, not a target: jitter may stretch the
255        // wait but must never bring the retry back before the participant said
256        // it would answer.
257        assert!(
258            started.elapsed() >= delay,
259            "the server's delay is a minimum, slept only {:?}",
260            started.elapsed()
261        );
262    }
263
264    #[tokio::test]
265    async fn does_not_retry_non_retriable_errors() {
266        let calls = Cell::new(0);
267        let result: Result<u32> = run_with_retry(Some(&fast()), || {
268            calls.set(calls.get() + 1);
269            async move { Err(Error::InvalidRequest("nope".to_string())) }
270        })
271        .await;
272
273        assert!(result.is_err());
274        assert_eq!(calls.get(), 1, "non-retriable errors are not retried");
275    }
276
277    #[tokio::test]
278    async fn gives_up_after_max_attempts() {
279        let calls = Cell::new(0);
280        let result: Result<u32> = run_with_retry(Some(&fast()), || {
281            calls.set(calls.get() + 1);
282            async move { Err(Error::Timeout) }
283        })
284        .await;
285
286        assert!(result.is_err());
287        assert_eq!(calls.get(), 3, "stops at max_attempts");
288    }
289
290    #[tokio::test]
291    async fn attempt_timeout_bounds_a_hung_attempt() {
292        let calls = Cell::new(0);
293        let config = fast()
294            .with_max_attempts(2)
295            .with_attempt_timeout(Duration::from_millis(5));
296        let result: Result<u32> = run_with_retry(Some(&config), || {
297            calls.set(calls.get() + 1);
298            async move {
299                // Hangs well past the per-attempt timeout, so each attempt is
300                // cancelled and retried as a timeout.
301                tokio::time::sleep(Duration::from_secs(30)).await;
302                Ok(1)
303            }
304        })
305        .await;
306
307        assert!(result.is_err(), "every attempt times out");
308        assert_eq!(calls.get(), 2, "the hung attempt is bounded and retried");
309    }
310
311    #[tokio::test]
312    async fn without_config_runs_exactly_once() {
313        let calls = Cell::new(0);
314        let result: Result<u32> = run_with_retry(None, || {
315            calls.set(calls.get() + 1);
316            async move { Err(Error::Timeout) }
317        })
318        .await;
319
320        assert!(result.is_err());
321        assert_eq!(calls.get(), 1);
322    }
323}