error_rail/async_ext/
retry.rs

1//! Async retry utilities with runtime-neutral design.
2//!
3//! This module provides retry policies and functions that work with any async
4//! runtime by accepting a sleep function as a parameter.
5
6use core::future::Future;
7use core::time::Duration;
8
9use crate::traits::TransientError;
10use crate::types::ComposableError;
11
12/// Defines a retry policy for async operations.
13///
14/// Implementations determine when and how long to wait between retry attempts.
15pub trait RetryPolicy: Clone {
16    /// Returns the delay before the next retry attempt, or `None` to stop retrying.
17    ///
18    /// # Arguments
19    ///
20    /// * `attempt` - The current attempt number (0-indexed)
21    ///
22    /// # Returns
23    ///
24    /// - `Some(Duration)` - Wait this duration before retrying
25    /// - `None` - Stop retrying (max attempts reached or policy exhausted)
26    fn next_delay(&mut self, attempt: u32) -> Option<Duration>;
27
28    /// Resets the policy to its initial state.
29    ///
30    /// Default implementation does nothing, suitable for stateless policies.
31    #[inline]
32    fn reset(&mut self) {}
33}
34
35/// Exponential backoff retry policy.
36///
37/// Each retry waits exponentially longer than the previous one, up to a maximum
38/// delay. This is the recommended policy for most network operations.
39///
40/// # Example
41///
42/// ```rust
43/// use error_rail::async_ext::ExponentialBackoff;
44/// use core::time::Duration;
45///
46/// let policy = ExponentialBackoff {
47///     initial_delay: Duration::from_millis(100),
48///     max_delay: Duration::from_secs(10),
49///     max_attempts: 5,
50///     multiplier: 2.0,
51/// };
52///
53/// // Delays: 100ms, 200ms, 400ms, 800ms, 1600ms (capped at 10s)
54/// ```
55#[derive(Clone, Copy, Debug)]
56pub struct ExponentialBackoff {
57    /// Initial delay before first retry.
58    pub initial_delay: Duration,
59    /// Maximum delay between retries.
60    pub max_delay: Duration,
61    /// Maximum number of retry attempts.
62    pub max_attempts: u32,
63    /// Multiplier applied to delay after each attempt.
64    pub multiplier: f64,
65}
66
67impl Default for ExponentialBackoff {
68    #[inline]
69    fn default() -> Self {
70        Self {
71            initial_delay: Duration::from_millis(100),
72            max_delay: Duration::from_secs(30),
73            max_attempts: 5,
74            multiplier: 2.0,
75        }
76    }
77}
78
79impl ExponentialBackoff {
80    /// Creates a new exponential backoff policy with default settings.
81    ///
82    /// The default configuration provides:
83    /// - Initial delay: 100 milliseconds
84    /// - Maximum delay: 30 seconds
85    /// - Maximum attempts: 5
86    /// - Multiplier: 2.0
87    #[inline]
88    pub const fn new() -> Self {
89        Self {
90            initial_delay: Duration::from_millis(100),
91            max_delay: Duration::from_secs(30),
92            max_attempts: 5,
93            multiplier: 2.0,
94        }
95    }
96
97    /// Sets the initial delay duration for the first retry attempt.
98    ///
99    /// This serves as the base value for the exponential calculation.
100    #[inline]
101    pub const fn with_initial_delay(mut self, delay: Duration) -> Self {
102        self.initial_delay = delay;
103        self
104    }
105
106    /// Sets the maximum duration allowed between retry attempts.
107    ///
108    /// The delay will never exceed this value regardless of the number of attempts
109    /// or the multiplier.
110    #[inline]
111    pub const fn with_max_delay(mut self, delay: Duration) -> Self {
112        self.max_delay = delay;
113        self
114    }
115
116    /// Sets the maximum number of retry attempts allowed.
117    ///
118    /// Once this number of retries is reached, the policy will stop suggesting delays.
119    #[inline]
120    pub const fn with_max_attempts(mut self, attempts: u32) -> Self {
121        self.max_attempts = attempts;
122        self
123    }
124
125    /// Sets the multiplier applied to the delay after each failed attempt.
126    ///
127    /// For example, a multiplier of `2.0` doubles the delay duration each time.
128    #[inline]
129    pub const fn with_multiplier(mut self, multiplier: f64) -> Self {
130        self.multiplier = multiplier;
131        self
132    }
133
134    /// Computes the delay for a given attempt number.
135    #[inline]
136    fn compute_delay(&self, attempt: u32) -> Duration {
137        let delay_secs = self.initial_delay.as_secs_f64() * self.multiplier.powi(attempt as i32);
138        let delay = Duration::from_secs_f64(delay_secs);
139        if delay > self.max_delay {
140            self.max_delay
141        } else {
142            delay
143        }
144    }
145}
146
147impl RetryPolicy for ExponentialBackoff {
148    #[inline]
149    fn next_delay(&mut self, attempt: u32) -> Option<Duration> {
150        if attempt >= self.max_attempts {
151            return None;
152        }
153        Some(self.compute_delay(attempt))
154    }
155}
156
157/// Fixed delay retry policy.
158///
159/// Waits the same duration between each retry attempt. This is simpler than
160/// exponential backoff but may not be suitable for services under heavy load.
161///
162/// # Example
163///
164/// ```rust
165/// use error_rail::async_ext::FixedDelay;
166/// use core::time::Duration;
167///
168/// let policy = FixedDelay::new(Duration::from_millis(500), 3);
169///
170/// // Delays: 500ms, 500ms, 500ms (then stops)
171/// ```
172#[derive(Clone, Copy, Debug)]
173pub struct FixedDelay {
174    /// Delay between retry attempts.
175    pub delay: Duration,
176    /// Maximum number of retry attempts.
177    pub max_attempts: u32,
178}
179
180impl FixedDelay {
181    /// Creates a new fixed delay policy.
182    #[inline]
183    pub const fn new(delay: Duration, max_attempts: u32) -> Self {
184        Self { delay, max_attempts }
185    }
186}
187
188impl RetryPolicy for FixedDelay {
189    #[inline]
190    fn next_delay(&mut self, attempt: u32) -> Option<Duration> {
191        if attempt >= self.max_attempts {
192            None
193        } else {
194            Some(self.delay)
195        }
196    }
197}
198
199/// Retries an async operation according to a policy when transient errors occur.
200///
201/// This function is **runtime-neutral**: it accepts a `sleep_fn` parameter that
202/// performs the actual sleeping, allowing it to work with any async runtime.
203///
204/// # Arguments
205///
206/// * `operation` - A closure that returns the future to retry
207/// * `policy` - The retry policy to use
208/// * `sleep_fn` - A function that returns a sleep future for the given duration
209///
210/// # Example
211///
212/// ```rust,ignore
213/// use error_rail::async_ext::{retry_with_policy, ExponentialBackoff};
214///
215/// // With Tokio
216/// let result = retry_with_policy(
217///     || fetch_data(),
218///     ExponentialBackoff::default(),
219///     |d| tokio::time::sleep(d),
220/// ).await;
221///
222/// // With async-std
223/// let result = retry_with_policy(
224///     || fetch_data(),
225///     ExponentialBackoff::default(),
226///     |d| async_std::task::sleep(d),
227/// ).await;
228/// ```
229pub async fn retry_with_policy<F, Fut, T, E, P, S, SFut>(
230    mut operation: F,
231    mut policy: P,
232    sleep_fn: S,
233) -> Result<T, ComposableError<E>>
234where
235    F: FnMut() -> Fut,
236    Fut: Future<Output = Result<T, E>>,
237    E: TransientError,
238    P: RetryPolicy,
239    S: Fn(Duration) -> SFut,
240    SFut: Future<Output = ()>,
241{
242    policy.reset();
243    let mut attempt = 0u32;
244
245    loop {
246        match operation().await {
247            Ok(value) => return Ok(value),
248            Err(e) => {
249                if !e.is_transient() {
250                    return Err(ComposableError::new(e)
251                        .with_context(crate::context!("permanent error, no retry")));
252                }
253
254                match policy.next_delay(attempt) {
255                    Some(delay) => {
256                        sleep_fn(delay).await;
257                        attempt += 1;
258                    },
259                    None => {
260                        return Err(ComposableError::new(e).with_context(crate::context!(
261                            "exhausted after {} attempts",
262                            attempt + 1
263                        )));
264                    },
265                }
266            },
267        }
268    }
269}
270
271/// Result of a retry operation with metadata about attempts.
272///
273/// This struct provides detailed information about a retry operation,
274/// including the final result and statistics about the retry process.
275///
276/// # Type Parameters
277///
278/// * `T` - The success type of the operation
279/// * `E` - The error type of the operation
280///
281/// # Example
282///
283/// ```rust,ignore
284/// use error_rail::async_ext::{retry_with_metadata, ExponentialBackoff, RetryResult};
285///
286/// let retry_result: RetryResult<Data, ApiError> = retry_with_metadata(
287///     || fetch_data(),
288///     ExponentialBackoff::default(),
289///     |d| tokio::time::sleep(d),
290/// ).await;
291///
292/// if retry_result.attempts > 1 {
293///     log::warn!(
294///         "Operation succeeded after {} attempts (waited {:?})",
295///         retry_result.attempts,
296///         retry_result.total_wait_time
297///     );
298/// }
299/// ```
300#[derive(Debug)]
301pub struct RetryResult<T, E> {
302    /// The final result of the operation.
303    ///
304    /// Contains `Ok(T)` if the operation eventually succeeded, or
305    /// `Err(ComposableError<E>)` if all retry attempts were exhausted
306    /// or a permanent error occurred.
307    pub result: Result<T, ComposableError<E>>,
308
309    /// Total number of attempts made.
310    ///
311    /// This is always at least 1 (the initial attempt). A value greater
312    /// than 1 indicates that retries occurred.
313    pub attempts: u32,
314
315    /// Total time spent waiting between retries.
316    ///
317    /// This does not include the time spent executing the operation itself,
318    /// only the delays between retry attempts. A value of `Duration::ZERO`
319    /// indicates either immediate success or immediate permanent failure.
320    pub total_wait_time: Duration,
321}
322
323impl<T, E> RetryResult<T, E> {
324    /// Returns `true` if the operation succeeded.
325    #[inline]
326    pub const fn is_ok(&self) -> bool {
327        self.result.is_ok()
328    }
329
330    /// Returns `true` if the operation failed.
331    #[inline]
332    pub const fn is_err(&self) -> bool {
333        self.result.is_err()
334    }
335
336    /// Returns `true` if retries were needed (more than one attempt).
337    #[inline]
338    pub const fn had_retries(&self) -> bool {
339        self.attempts > 1
340    }
341}
342
343/// Retries an operation with detailed result metadata.
344///
345/// Similar to [`retry_with_policy`], but returns additional information about
346/// the retry process, including the number of attempts made and total wait time.
347///
348/// # Arguments
349///
350/// * `operation` - A closure that returns the future to retry
351/// * `policy` - The retry policy to use
352/// * `sleep_fn` - A function that returns a sleep future for the given duration
353///
354/// # Returns
355///
356/// A [`RetryResult`] containing:
357/// - The final result (success or error with context)
358/// - Total number of attempts made
359/// - Total time spent waiting between retries
360///
361/// # Example
362///
363/// ```rust,ignore
364/// use error_rail::async_ext::{retry_with_metadata, ExponentialBackoff};
365///
366/// let retry_result = retry_with_metadata(
367///     || fetch_data(),
368///     ExponentialBackoff::default(),
369///     |d| tokio::time::sleep(d),
370/// ).await;
371///
372/// println!("Attempts: {}", retry_result.attempts);
373/// println!("Total wait time: {:?}", retry_result.total_wait_time);
374///
375/// match retry_result.result {
376///     Ok(data) => println!("Success: {:?}", data),
377///     Err(e) => println!("Failed after retries: {:?}", e),
378/// }
379/// ```
380pub async fn retry_with_metadata<F, Fut, T, E, P, S, SFut>(
381    mut operation: F,
382    mut policy: P,
383    sleep_fn: S,
384) -> RetryResult<T, E>
385where
386    F: FnMut() -> Fut,
387    Fut: Future<Output = Result<T, E>>,
388    E: TransientError,
389    P: RetryPolicy,
390    S: Fn(Duration) -> SFut,
391    SFut: Future<Output = ()>,
392{
393    policy.reset();
394    let mut attempt = 0u32;
395    let mut total_wait_time = Duration::ZERO;
396
397    let result = loop {
398        match operation().await {
399            Ok(value) => break Ok(value),
400            Err(e) => {
401                if !e.is_transient() {
402                    break Err(ComposableError::new(e)
403                        .with_context(crate::context!("permanent error, no retry")));
404                }
405
406                match policy.next_delay(attempt) {
407                    Some(delay) => {
408                        total_wait_time += delay;
409                        sleep_fn(delay).await;
410                        attempt += 1;
411                    },
412                    None => {
413                        break Err(ComposableError::new(e).with_context(crate::context!(
414                            "exhausted after {} attempts",
415                            attempt + 1
416                        )));
417                    },
418                }
419            },
420        }
421    };
422
423    RetryResult { result, attempts: attempt + 1, total_wait_time }
424}