shared-framework 0.0.17

Reusable building blocks for HTTP services — Hyper routing, SeaORM data layer, validation, OpenAPI docs, jobs, queues, cache.
Documentation
//! Exponential-backoff retry for async operations.
//!
//! Build a [`RetryStrategy`] with the desired delays and attempt limit, then
//! run an operation with [`retry`](RetryStrategy::retry) or
//! [`with_exponential_backoff`](RetryStrategy::with_exponential_backoff).
//! Failures sleep before the next attempt with the delay doubling each time up
//! to the maximum; exhaustion returns [`RetryExhaustedError`].
//!
//! Key types: [`RetryStrategy`] for configuration and execution,
//! [`RetryExhaustedError`] for the terminal failure.
//!
//! Use this module when a call may fail transiently and is safe to repeat.
//!
//! ```ignore
//! # use std::time::Duration;
//! # use crate::retry::RetryStrategy;
//! # async fn example() -> Result<String, crate::retry::RetryExhaustedError> {
//! let strategy = RetryStrategy::new()
//!     .with_base_delay(Duration::from_millis(100))
//!     .with_max_delay(Duration::from_secs(5))
//!     .with_max_attempts(3);
//!
//! strategy.retry(|| async { Ok::<_, std::io::Error>("ok".to_string()) }).await
//! # }
//! ```

use std::time::Duration;
use thiserror::Error;

/// Terminal failure after all retry attempts were used.
///
/// Carries the number of attempts made and the last error as an `anyhow` error.
#[derive(Debug, Error)]
#[error("retry exhausted after {attempts} attempts: {source}")]
pub struct RetryExhaustedError {
    /// Number of attempts made, including the final one.
    pub attempts: usize,
    /// The last error seen before giving up.
    pub source: anyhow::Error,
}

/// Configurable exponential-backoff policy.
///
/// Defaults: 1-second base delay, 30-second maximum delay, 12 attempts.
/// Builders ([`with_base_delay`](Self::with_base_delay),
/// [`with_max_delay`](Self::with_max_delay),
/// [`with_max_attempts`](Self::with_max_attempts)) return the updated strategy.
#[derive(Clone)]
pub struct RetryStrategy {
    base_delay: Duration,
    max_delay: Duration,
    max_attempts: usize,
}

impl RetryStrategy {
    /// Creates a strategy with 1-second base delay, 30-second max delay, and 12 attempts.
    pub fn new() -> Self {
        Self { base_delay: Duration::from_secs(1), max_delay: Duration::from_secs(30), max_attempts: 12 }
    }

    /// Sets the initial delay before the first retry.
    pub fn with_base_delay(mut self, d: Duration) -> Self { self.base_delay = d; self }
    /// Sets the upper bound for the doubling delay.
    pub fn with_max_delay(mut self, d: Duration) -> Self { self.max_delay = d; self }
    /// Sets how many total attempts are made before giving up.
    pub fn with_max_attempts(mut self, n: usize) -> Self { self.max_attempts = n; self }

    /// Runs a boxed-future operation with exponential backoff.
    ///
    /// `F` is the closure producing each attempt's boxed future, `T` the success
    /// value, and `E` the per-attempt error (converted to text on exhaustion).
    /// Returns the first success, or [`RetryExhaustedError`] after `max_attempts`.
    pub async fn with_exponential_backoff<F, T, E>(&self, mut f: F) -> Result<T, RetryExhaustedError>
    where
        F: FnMut() -> futures::future::BoxFuture<'static, Result<T, E>> + Send,
        E: std::fmt::Display + Send + Sync + 'static,
        T: Send,
    {
        let mut attempt = 0usize;
        let mut delay = self.base_delay;
        loop {
            attempt += 1;
            match f().await {
                Ok(v) => return Ok(v),
                Err(e) => {
                    if attempt >= self.max_attempts {
                        return Err(RetryExhaustedError { attempts: attempt, source: anyhow::anyhow!(e.to_string()) });
                    }
                    tokio::time::sleep(delay).await;
                    delay = std::cmp::min(delay * 2, self.max_delay);
                }
            }
        }
    }

    /// Runs an operation returning a future with exponential backoff.
    ///
    /// `F` is the closure producing each attempt, `Fut` its future, `T` the
    /// success value, and `E` the per-attempt error (converted to text on
    /// exhaustion). Returns the first success, or [`RetryExhaustedError`]
    /// after `max_attempts`.
    pub async fn retry<F, Fut, T, E>(&self, mut op: F) -> Result<T, RetryExhaustedError>
    where
        F: FnMut() -> Fut + Send,
        Fut: std::future::Future<Output = Result<T, E>> + Send,
        E: std::fmt::Display + Send + Sync + 'static,
    {
        let mut attempt = 0usize;
        let mut delay = self.base_delay;
        loop {
            attempt += 1;
            match op().await {
                Ok(v) => return Ok(v),
                Err(e) => {
                    if attempt >= self.max_attempts {
                        return Err(RetryExhaustedError { attempts: attempt, source: anyhow::anyhow!(e.to_string()) });
                    }
                    tokio::time::sleep(delay).await;
                    delay = std::cmp::min(delay * 2, self.max_delay);
                }
            }
        }
    }
}

impl Default for RetryStrategy {
    fn default() -> Self { Self::new() }
}