Skip to content

Instantly share code, notes, and snippets.

@menjaraz
Last active June 16, 2026 04:16
Show Gist options
  • Select an option

  • Save menjaraz/e7d5ced77cdaf38e2c498665c99fd88b to your computer and use it in GitHub Desktop.

Select an option

Save menjaraz/e7d5ced77cdaf38e2c498665c99fd88b to your computer and use it in GitHub Desktop.
Rust asynchronous retry logic using tokio_retry with ExponentialBackoff, jitter, and conditional error handling (RetryIf).

Async Retry Logic in Rust with tokio-retry

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.

πŸš€ Key Features Demonstrated

  • 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.

πŸ› οΈ Project Setup

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"

πŸ’» Code Implementation

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),
    }
}

πŸ“Š How it Works (Expected Output)

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


πŸ’‘ Quick Tips for Async Closures

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment