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