Async Retry with Exponential Backoff

Retry fallible async operations with capped exponential delays, per-attempt jitter, and a final error carrying the attempt count.

RustResilience

Code

snippet
use std::future::Future;
use std::time::Duration;
use tokio::time::sleep;

#[derive(Debug)]
struct RetryError {
    attempts: u32,
    last: String,
}

async fn retry<F, Fut, T, E>(
    attempts: u32,
    base: Duration,
    mut operation: F,
) -> Result<T, RetryError>
where
    F: FnMut(u32) -> Fut,
    Fut: Future<Output = Result<T, E>>,
    E: std::fmt::Display,
{
    if attempts == 0 {
        return Err(RetryError {
            attempts,
            last: "attempts must be positive".to_string(),
        });
    }

    let mut last = String::new();
    for attempt in 1..=attempts {
        match operation(attempt).await {
            Ok(value) => return Ok(value),
            Err(error) => {
                last = error.to_string();
                if attempt < attempts {
                    let shift = (attempt - 1).min(5);
                    let jitter_ms = u64::from(attempt * 37 % 11);
                    let delay = base * (1u32 << shift) + Duration::from_millis(jitter_ms);
                    sleep(delay).await;
                }
            }
        }
    }
    Err(RetryError { attempts, last })
}

#[tokio::main]
async fn main() {
    let mut calls = 0u32;
    let result = retry(4, Duration::from_millis(10), |_attempt| {
        calls += 1;
        let current = calls;
        async move {
            if current < 3 {
                Err(format!("attempt {current} failed"))
            } else {
                Ok(current)
            }
        }
    })
    .await;
    println!("{result:?}");
}