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}