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