This Gist demonstrates a production-ready pattern for executing asynchronous operations with exponential backoff, jitter, and conditional error handling using the tokio-retry crate.
It specifically showcases how to handle shared state safely across retry boundaries using thread-safe atomics.
- Exponential Backoff with Jitter: Automatically increases delays between retries to prevent overwhelming downstream services, adding randomness (
jitter) to avoid the thundering herd problem. - Conditional Retries (
RetryIf): Inspects the returned error to differentiate between transient failures (which should be retried) and fatal errors (which should abort immediately). - Safe Shared State: Uses
Arc<AtomicUsize>within nested async closures to track attempt counts across separate retry futures without running into lifetime or borrow checker issues.
Ensure your Cargo.toml is configured using the Rust 2024 edition and the following dependencies:
[package]
name = "retry-ex"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-retry = "0.3.1"
use tokio_retry::{RetryIf, strategy::{ExponentialBackoff, jitter}};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering}
};
use std::time::Duration;
#[derive(Debug)]
enum MyError {
Temporary,
Permanent,
}
// Simulated async operation that tracks execution attempts
async fn action(attempt_counter: Arc<AtomicUsize>) -> Result<u64, MyError> {
let attempt = attempt_counter.fetch_add(1, Ordering::SeqCst) + 1;
println!("Attempt #{attempt}");
match attempt {
1 | 2 => {
println!(" -> temporary failure");
Err(MyError::Temporary)
}
3 => {
println!(" -> permanent failure (stop)");
Err(MyError::Permanent)
}
_ => {
println!(" -> success!");
Ok(42)
}
}
}
#[tokio::main]
async fn main() {
let attempts = Arc::new(AtomicUsize::new(0));
// 1. Configure backoff: Start at 50ms, cap at 1s, apply jitter, max 5 attempts
let strategy = ExponentialBackoff::from_millis(50)
.max_delay(Duration::from_secs(1))
.map(jitter)
.take(5);
// 2. Clone the Arc into the closure scope so it can be invoked multiple times safely
let action_closure = {
let attempts = attempts.clone();
move || {
let attempts = attempts.clone();
async move { action(attempts).await }
}
};
// 3. Define the retry condition: Only retry if the error matches `MyError::Temporary`
let should_retry = |e: &MyError| matches!(e, MyError::Temporary);
// 4. Execute the retry loop
match RetryIf::spawn(strategy, action_closure, should_retry).await {
Ok(v) => println!("\nFinal success with value: {v}"),
Err(e) => eprintln!("\nFinal failure: {:?}", e),
}
}When you run this program, you will notice that even though take(5) allows up to 5 attempts, the execution halts early on Attempt #3 because RetryIf intercepts the MyError::Permanent variation and breaks the loop.
Attempt #1
-> temporary failure
Attempt #2
-> temporary failure
Attempt #3
-> permanent failure (stop)
Final failure: Permanent
Because tokio_retry needs to create a new Future every time an attempt fails, any state passed into the closure must either be cheap to clone or wrapped in an atomic wrapper like Arc. The nested closure structure used here:
move || {
let attempts = attempts.clone();
async move { ... }
}Is the standard idiomatic pattern in Rust to bypass the compiler's strict ownership requirements when capturing variables into repeatedly invoked async blocks.