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                tokio::time::sleep(with_jitter(backoff)).await;
105                backoff = (backoff * 2).min(config.max_backoff);
106                attempt += 1;
107            }
108            Err(err) => return Err(err),
109        }
110    }
111}
112
113/// Equal jitter (×0.5–1.5) on a backoff delay, so retries from many clients
114/// failing at once do not re-arrive in lockstep (thundering herd). Uses the
115/// clock's sub-second nanos as a cheap entropy source — no RNG dependency.
116fn with_jitter(backoff: Duration) -> Duration {
117    let nanos = std::time::SystemTime::now()
118        .duration_since(std::time::UNIX_EPOCH)
119        .map_or(0, |d| d.subsec_nanos());
120    let factor = 0.5 + f64::from(nanos % 1024) / 1024.0;
121    backoff.mul_f64(factor)
122}
123
124#[cfg(test)]
125#[allow(clippy::unwrap_used)]
126mod tests {
127    use super::*;
128    use crate::Error;
129    use std::cell::Cell;
130
131    fn fast() -> RetryConfig {
132        RetryConfig::default()
133            .with_initial_backoff(Duration::from_millis(1))
134            .with_max_backoff(Duration::from_millis(1))
135    }
136
137    #[tokio::test]
138    async fn retries_retriable_errors_then_succeeds() {
139        let calls = Cell::new(0);
140        let result: Result<u32> = run_with_retry(Some(&fast()), || {
141            calls.set(calls.get() + 1);
142            let n = calls.get();
143            async move { if n < 3 { Err(Error::Timeout) } else { Ok(n) } }
144        })
145        .await;
146
147        assert_eq!(result.unwrap(), 3);
148        assert_eq!(calls.get(), 3);
149    }
150
151    #[tokio::test]
152    async fn does_not_retry_non_retriable_errors() {
153        let calls = Cell::new(0);
154        let result: Result<u32> = run_with_retry(Some(&fast()), || {
155            calls.set(calls.get() + 1);
156            async move { Err(Error::InvalidRequest("nope".to_string())) }
157        })
158        .await;
159
160        assert!(result.is_err());
161        assert_eq!(calls.get(), 1, "non-retriable errors are not retried");
162    }
163
164    #[tokio::test]
165    async fn gives_up_after_max_attempts() {
166        let calls = Cell::new(0);
167        let result: Result<u32> = run_with_retry(Some(&fast()), || {
168            calls.set(calls.get() + 1);
169            async move { Err(Error::Timeout) }
170        })
171        .await;
172
173        assert!(result.is_err());
174        assert_eq!(calls.get(), 3, "stops at max_attempts");
175    }
176
177    #[tokio::test]
178    async fn attempt_timeout_bounds_a_hung_attempt() {
179        let calls = Cell::new(0);
180        let config = fast()
181            .with_max_attempts(2)
182            .with_attempt_timeout(Duration::from_millis(5));
183        let result: Result<u32> = run_with_retry(Some(&config), || {
184            calls.set(calls.get() + 1);
185            async move {
186                // Hangs well past the per-attempt timeout, so each attempt is
187                // cancelled and retried as a timeout.
188                tokio::time::sleep(Duration::from_secs(30)).await;
189                Ok(1)
190            }
191        })
192        .await;
193
194        assert!(result.is_err(), "every attempt times out");
195        assert_eq!(calls.get(), 2, "the hung attempt is bounded and retried");
196    }
197
198    #[tokio::test]
199    async fn without_config_runs_exactly_once() {
200        let calls = Cell::new(0);
201        let result: Result<u32> = run_with_retry(None, || {
202            calls.set(calls.get() + 1);
203            async move { Err(Error::Timeout) }
204        })
205        .await;
206
207        assert!(result.is_err());
208        assert_eq!(calls.get(), 1);
209    }
210}