relentless 0.7.0

Composable retry policies for fallible operations and polling.
Documentation

relentless

crates.io docs.rs CI MSRV

Retry and polling for Rust — with composable strategies, policy reuse, and first-class support for polling workflows where Ok(_) doesn't always mean "done."

Most retry libraries handle the simple case well: call a function, retry on error, back off. relentless handles that too, but it also handles the cases those libraries make awkward:

  • Polling, where Ok("pending") means "keep going" and you need .until(predicate::ok(...)) rather than just retrying errors.
  • Policy reuse, where a single RetryPolicy captures your retry rules and gets shared across multiple call sites — no duplicated builder chains.
  • Strategy composition, where wait::fixed(50ms) + wait::exponential(100ms) and stop::attempts(5) | stop::elapsed(2s) express complex behavior in one line.
  • Hooks and stats, where you observe the retry lifecycle (logging, metrics) without restructuring your retry logic.

All of this works in sync and async code, across std, no_std, and wasm targets.

Inspired by Python's tenacity (composable strategy algebra) and Rust's backon (ergonomic retry builders).

Install

cargo add relentless

Feature flags

Flag Purpose
std (default) std::thread::sleep fallback, Instant elapsed clock, std::error::Error on RetryError
alloc Boxed policies, closure elapsed clocks, multiple hooks per point
tokio-sleep sleep::tokio() async sleep adapter
embassy-sleep sleep::embassy() async sleep adapter
gloo-timers-sleep sleep::gloo() async sleep adapter (wasm32)
futures-timer-sleep sleep::futures_timer() async sleep adapter

Async retry does not require alloc.


Examples

For full docs, see https://docs.rs/relentless. Behavior spec: docs/SPEC.md. Runnable examples live in examples/.

Sync examples omit .sleep(...) because std builds fall back to std::thread::sleep automatically. Without std, pass an explicit sleeper before .call().

1) Retry with defaults

The .retry() extension trait is the fastest way to add retries. Defaults: 3 attempts, exponential backoff from 100 ms, retry on any Err.

use relentless::RetryExt;

fn fetch_job_output() -> Result<String, std::io::Error> {
    std::fs::read_to_string("/var/run/background_job.output")
}

let results = fetch_job_output.retry().call();

2) Customized retry

The retry free function is equivalent to the extension trait, with the added ability to capture retry loop state. Both the free function and extension trait give full control over which errors to retry, how long to wait, and when to stop.

use core::time::Duration;
use relentless::{Wait, retry, predicate, stop, wait};

let body = retry(|state| {
    println!("attempt {}", state.attempt);
    reqwest::blocking::get("https://api.example.com/data")?.text()
})
.when(predicate::error(|e: &reqwest::Error| e.is_timeout()))
.wait(
    wait::exponential(Duration::from_millis(200))
        .full_jitter()
        .cap(Duration::from_secs(5)),
)
.stop(stop::attempts(10))
.timeout(Duration::from_secs(30))
.call();

3) Reuse a policy across call sites

RetryPolicy captures retry rules once. Compose wait strategies with + and stop strategies with | or &.

use core::time::Duration;
use relentless::{RetryPolicy, stop, wait};

fn check_health() -> Result<String, std::io::Error> { todo!() }
fn fetch_invoice(id: &str) -> Result<String, std::io::Error> { todo!() }

let policy = RetryPolicy::new()
    .wait(
        wait::fixed(Duration::from_millis(50))
            + wait::exponential(Duration::from_millis(100)),
    )
    .stop(stop::attempts(5) | stop::elapsed(Duration::from_secs(30)));

// Same policy, different operations.
let health = policy.retry(|_| check_health()).call();
let invoice = policy.retry(|_| fetch_invoice("inv_123")).call();

4) Poll for a condition

Use .until(predicate) to keep retrying until a success condition is met. Unlike .when(), which retries on matching outcomes, .until() retries on everything except the matching outcome.

use relentless::{RetryPolicy, predicate};

#[derive(Debug, PartialEq)]
enum Status { Pending, Done }

fn poll_status() -> Result<Status, std::io::Error> { todo!() }

let result = RetryPolicy::new()
    .until(predicate::ok(|s: &Status| *s == Status::Done))
    .retry(|_| poll_status())
    .call();

To also retry selected errors during polling, use predicate::result:

use relentless::{RetryPolicy, predicate};

#[derive(Debug)]
enum Status { Pending, Done }
#[derive(Debug)]
enum Error { Retryable, Fatal }

fn poll_job() -> Result<Status, Error> { todo!() }

// Retry until Done or Fatal; keep going on Pending or Retryable.
let result = RetryPolicy::new()
    .until(predicate::result(|outcome: &Result<Status, Error>| {
        matches!(outcome, Ok(Status::Done) | Err(Error::Fatal))
    }))
    .retry(|_| poll_job())
    .call();

5) Async retry

Pass an async sleep adapter — here via the tokio-sleep feature.

use relentless::retry_async;

async fn fetch(url: &str) -> Result<String, reqwest::Error> {
    reqwest::get(url).await?.text().await
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let body = retry_async(|_| fetch("https://api.example.com/data"))
        .sleep(relentless::sleep::tokio())
        .await?;
    Ok(())
}

6) Hooks & stats

use relentless::retry;

let (result, stats) = retry(|_| Ok::<_, &str>("done"))
    .before_attempt(|state| {
        if state.attempt > 1 {
            println!("retrying (attempt {})", state.attempt);
        }
    })
    .after_attempt(|state| {
        if let Err(e) = state.outcome {
            eprintln!("attempt {} failed: {e}", state.attempt);
        }
    })
    .with_stats()
    .call();

println!("attempts: {}, total wait: {:?}", stats.attempts, stats.total_wait);

7) Error handling

use relentless::{retry, RetryError};

match retry(|_| Err::<(), &str>("boom")).call() {
    Ok(val) => println!("success: {val:?}"),
    Err(RetryError::Exhausted { last }) => {
        // Stop strategy fired; last is the final attempt's Result.
        println!("gave up: {last:?}");
    }
    Err(RetryError::Rejected { last }) => {
        // Predicate decided this error is non-retryable.
        println!("non-retryable: {last}");
    }
}

API surface at a glance

Area Items
Entry points retry, retry_async (free functions); RetryExt, AsyncRetryExt (extension traits)
Policy RetryPolicy<S, W, P> with .retry(), .retry_async()
Stop strategies stop::attempts, stop::elapsed, stop::never
Wait strategies wait::fixed, wait::linear, wait::exponential, wait::decorrelated_jitter
Predicates predicate::any_error, predicate::error, predicate::ok, predicate::result
Execution builders SyncRetryBuilder / AsyncRetryBuilder with hooks, stats, timeout
Terminal types RetryError<T, E> (Exhausted, Rejected), RetryResult<T, E>, RetryStats, StopReason

Builder methods follow the order: when/until -> wait -> stop -> sleep -> hooks -> stats -> call.

MSRV

Minimum supported Rust version: 1.85.

Contributing

See CONTRIBUTING.md.

Release notes

For user-facing changes, see the changelog.

License

Licensed under either: