Skip to main content

shared_framework/retry/
mod.rs

1//! Exponential-backoff retry for async operations.
2//!
3//! Build a [`RetryStrategy`] with the desired delays and attempt limit, then
4//! run an operation with [`retry`](RetryStrategy::retry) or
5//! [`with_exponential_backoff`](RetryStrategy::with_exponential_backoff).
6//! Failures sleep before the next attempt with the delay doubling each time up
7//! to the maximum; exhaustion returns [`RetryExhaustedError`].
8//!
9//! Key types: [`RetryStrategy`] for configuration and execution,
10//! [`RetryExhaustedError`] for the terminal failure.
11//!
12//! Use this module when a call may fail transiently and is safe to repeat.
13//!
14//! ```ignore
15//! # use std::time::Duration;
16//! # use crate::retry::RetryStrategy;
17//! # async fn example() -> Result<String, crate::retry::RetryExhaustedError> {
18//! let strategy = RetryStrategy::new()
19//!     .with_base_delay(Duration::from_millis(100))
20//!     .with_max_delay(Duration::from_secs(5))
21//!     .with_max_attempts(3);
22//!
23//! strategy.retry(|| async { Ok::<_, std::io::Error>("ok".to_string()) }).await
24//! # }
25//! ```
26
27use std::time::Duration;
28use thiserror::Error;
29
30/// Terminal failure after all retry attempts were used.
31///
32/// Carries the number of attempts made and the last error as an `anyhow` error.
33#[derive(Debug, Error)]
34#[error("retry exhausted after {attempts} attempts: {source}")]
35pub struct RetryExhaustedError {
36    /// Number of attempts made, including the final one.
37    pub attempts: usize,
38    /// The last error seen before giving up.
39    pub source: anyhow::Error,
40}
41
42/// Configurable exponential-backoff policy.
43///
44/// Defaults: 1-second base delay, 30-second maximum delay, 12 attempts.
45/// Builders ([`with_base_delay`](Self::with_base_delay),
46/// [`with_max_delay`](Self::with_max_delay),
47/// [`with_max_attempts`](Self::with_max_attempts)) return the updated strategy.
48#[derive(Clone)]
49pub struct RetryStrategy {
50    base_delay: Duration,
51    max_delay: Duration,
52    max_attempts: usize,
53}
54
55impl RetryStrategy {
56    /// Creates a strategy with 1-second base delay, 30-second max delay, and 12 attempts.
57    pub fn new() -> Self {
58        Self { base_delay: Duration::from_secs(1), max_delay: Duration::from_secs(30), max_attempts: 12 }
59    }
60
61    /// Sets the initial delay before the first retry.
62    pub fn with_base_delay(mut self, d: Duration) -> Self { self.base_delay = d; self }
63    /// Sets the upper bound for the doubling delay.
64    pub fn with_max_delay(mut self, d: Duration) -> Self { self.max_delay = d; self }
65    /// Sets how many total attempts are made before giving up.
66    pub fn with_max_attempts(mut self, n: usize) -> Self { self.max_attempts = n; self }
67
68    /// Runs a boxed-future operation with exponential backoff.
69    ///
70    /// `F` is the closure producing each attempt's boxed future, `T` the success
71    /// value, and `E` the per-attempt error (converted to text on exhaustion).
72    /// Returns the first success, or [`RetryExhaustedError`] after `max_attempts`.
73    pub async fn with_exponential_backoff<F, T, E>(&self, mut f: F) -> Result<T, RetryExhaustedError>
74    where
75        F: FnMut() -> futures::future::BoxFuture<'static, Result<T, E>> + Send,
76        E: std::fmt::Display + Send + Sync + 'static,
77        T: Send,
78    {
79        let mut attempt = 0usize;
80        let mut delay = self.base_delay;
81        loop {
82            attempt += 1;
83            match f().await {
84                Ok(v) => return Ok(v),
85                Err(e) => {
86                    if attempt >= self.max_attempts {
87                        return Err(RetryExhaustedError { attempts: attempt, source: anyhow::anyhow!(e.to_string()) });
88                    }
89                    tokio::time::sleep(delay).await;
90                    delay = std::cmp::min(delay * 2, self.max_delay);
91                }
92            }
93        }
94    }
95
96    /// Runs an operation returning a future with exponential backoff.
97    ///
98    /// `F` is the closure producing each attempt, `Fut` its future, `T` the
99    /// success value, and `E` the per-attempt error (converted to text on
100    /// exhaustion). Returns the first success, or [`RetryExhaustedError`]
101    /// after `max_attempts`.
102    pub async fn retry<F, Fut, T, E>(&self, mut op: F) -> Result<T, RetryExhaustedError>
103    where
104        F: FnMut() -> Fut + Send,
105        Fut: std::future::Future<Output = Result<T, E>> + Send,
106        E: std::fmt::Display + Send + Sync + 'static,
107    {
108        let mut attempt = 0usize;
109        let mut delay = self.base_delay;
110        loop {
111            attempt += 1;
112            match op().await {
113                Ok(v) => return Ok(v),
114                Err(e) => {
115                    if attempt >= self.max_attempts {
116                        return Err(RetryExhaustedError { attempts: attempt, source: anyhow::anyhow!(e.to_string()) });
117                    }
118                    tokio::time::sleep(delay).await;
119                    delay = std::cmp::min(delay * 2, self.max_delay);
120                }
121            }
122        }
123    }
124}
125
126impl Default for RetryStrategy {
127    fn default() -> Self { Self::new() }
128}