Skip to main content

Crate relentless

Crate relentless 

Source
Expand description

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

§Quick start

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

use relentless::RetryExt;

let result = (|| Ok::<_, &str>(42)).retry().sleep(|_| {}).call();
assert_eq!(result.unwrap(), 42);

The retry and retry_async free functions are equivalent, with the added ability to capture retry loop state via the RetryState argument:

use relentless::retry;

let result = retry(|state| {
    if state.attempt >= 2 { Ok(state.attempt) } else { Err("not yet") }
})
.sleep(|_| {})
.call();

assert_eq!(result.unwrap(), 2);

§Customizing retry behavior

Builder methods control when to retry, how long to wait, and when to stop:

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

let result = retry(|_| Err::<(), &str>("boom"))
    .when(predicate::error(|e: &&str| *e == "boom"))
    .wait(
        wait::exponential(Duration::from_millis(100))
            .full_jitter()
            .cap(Duration::from_secs(5)),
    )
    .stop(stop::attempts(3))
    .sleep(|_| {})
    .call();

assert!(result.is_err());

To bound the total wall-clock time across all attempts and sleeps, add .timeout(dur). It OR-folds an elapsed deadline into the stop strategy and clamps each inter-attempt sleep to the remaining budget; it does not interrupt an attempt already running. See Cancellation for the runtime-agnostic deadline pattern.

§Policy reuse

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

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

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 a = policy.retry(|_| Ok::<_, &str>("a")).sleep(|_| {}).call();
let b = policy.retry(|_| Ok::<_, &str>("b")).sleep(|_| {}).call();

assert_eq!(a.unwrap(), "a");
assert_eq!(b.unwrap(), "b");

§Polling 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 }

let mut count = 0;
let result = RetryPolicy::new()
    .until(predicate::ok(|s: &Status| *s == Status::Done))
    .retry(|_| {
        count += 1;
        Ok::<_, &str>(if count >= 2 { Status::Done } else { Status::Pending })
    })
    .sleep(|_| {})
    .call();

assert_eq!(result.unwrap(), Status::Done);

§Hooks and 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);
        }
    })
    .sleep(|_| {})
    .with_stats()
    .call();

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

§Error handling

The retry loop returns Ok(T) on success. On failure it returns RetryError, which distinguishes between exhaustion (stop strategy fired) and rejection (predicate deemed the error non-retryable):

use relentless::{retry, RetryError};

match retry(|_| Err::<(), &str>("boom")).sleep(|_| {}).call() {
    Ok(val) => println!("success: {val:?}"),
    Err(RetryError::Exhausted { last }) => {
        println!("gave up: {last:?}");
    }
    Err(RetryError::Rejected { last }) => {
        println!("non-retryable: {last}");
    }
    // `RetryError` is `#[non_exhaustive]`; match future variants here.
    Err(_) => {}
}

§Cancellation

By design, the retry engine has no built-in cancellation primitive — it composes with the cancellation your environment already provides, observed at attempt boundaries (a running operation or sleep is never interrupted mid-flight).

  • Async: the future returned by .call() is cancel-safe — drop it (e.g. via tokio::time::timeout or select!) to stop at the next .await. Note on_exit does not fire on drop; use Drop on your own types for guaranteed cleanup. See the async-cancel example.
  • Sync: bound the wall-clock with .timeout(), or check a flag inside the operation and return a sentinel error. With the default any_error() predicate that sentinel is retried, so make it non-retryable via .when() (it then terminates as RetryError::Rejected). See the sync-cancel example.

§Custom wait strategies

Implement Wait to build your own wait strategies. All combinators (.cap(), .full_jitter(), .chain(), +) work on any Wait implementor.

use core::time::Duration;
use relentless::{RetryState, Wait, wait};

struct CustomWait(Duration);

impl Wait for CustomWait {
    fn next_wait(&self, _state: &RetryState) -> Duration {
        self.0
    }
}

let strategy = CustomWait(Duration::from_millis(20))
    .cap(Duration::from_millis(15))
    .chain(wait::fixed(Duration::from_millis(50)), 2);

let state = RetryState::new(3, None);
assert_eq!(strategy.next_wait(&state), Duration::from_millis(50));

§Feature flags

FlagPurpose
std (default)std::thread::sleep fallback, Instant elapsed clock, std::error::Error on RetryError
allocBoxed policies, closure elapsed clocks, multiple hooks per point
test-utiltest_util::VirtualClock — deterministic testing of retry behavior without real sleeps
tokio-sleepsleep::tokio() async sleep adapter
embassy-sleepsleep::embassy() async sleep adapter
gloo-timers-sleepsleep::gloo() async sleep adapter (wasm32)
futures-timer-sleepsleep::futures_timer() async sleep adapter

Async retry does not require alloc. Sync std builds automatically fall back to std::thread::sleep, so .sleep(...) is optional.

Re-exports§

pub use predicate::Predicate;
pub use sleep::Sleeper;
pub use stop::Stop;
pub use wait::Wait;

Modules§

predicate
Predicate trait and built-in retry predicate factories.
prelude
Commonly used traits, glob-imported to enable the builder DSL.
sleep
Async sleep abstractions used by the retry engine between attempts.
stop
Stop trait and built-in stop strategies.
test_util
Deterministic virtual-clock test infrastructure.
wait
Wait trait and built-in wait strategies.

Structs§

AsyncRetryExec
Async retry execution object, generic over how the policy is stored.
AsyncRetryExecWithStats
Async retry execution wrapper that also yields RetryStats.
AttemptState
Read-only context passed to the after_attempt hook.
ExitState
Final read-only context passed to the on_exit hook.
RetryPolicy
Reusable retry configuration.
RetryState
Non-generic retry context passed to Stop::should_stop, Wait::next_wait, the operation, and the before_attempt hook.
RetryStats
Aggregate statistics for a completed retry execution.
SyncRetryExec
Sync retry execution object, generic over how the policy is stored.
SyncRetryExecWithStats
Sync retry execution wrapper that returns statistics.

Enums§

RetryError
Error returned when a retry loop terminates without producing an accepted result.
StopReason
Why a retry loop terminated.

Traits§

AsyncRetryExt
Extension trait to start async retries directly from a closure/function.
RetryExt
Extension trait to start sync retries directly from a closure/function.

Functions§

retry
Returns a SyncRetryBuilder with default policy: attempts(3), exponential(100ms), any_error().
retry_async
Returns an AsyncRetryBuilder with default policy: attempts(3), exponential(100ms), any_error().

Type Aliases§

AsyncRetry
Async retry execution object created by RetryPolicy::retry_async.
AsyncRetryBuilder
Owned async retry builder created from AsyncRetryExt::retry_async or crate::retry_async.
AsyncRetryBuilderWithStats
Owned async retry builder wrapper that returns statistics.
AsyncRetryWithStats
Async retry execution-with-stats object created by .with_stats() on AsyncRetry.
DefaultAsyncRetryBuilder
Alias for the default owned async retry builder returned by AsyncRetryExt::retry_async.
DefaultAsyncRetryBuilderWithStats
Alias for the default owned async retry builder-with-stats returned by calling .with_stats() on AsyncRetryExt::retry_async.
DefaultSyncRetryBuilder
Alias for the default owned sync retry builder returned by RetryExt::retry.
DefaultSyncRetryBuilderWithStats
Alias for the default owned sync retry builder-with-stats returned by calling .with_stats() on RetryExt::retry.
RetryResult
Convenience alias: Result<T, RetryError<T, E>>.
SyncRetry
Sync retry execution object created by RetryPolicy::retry.
SyncRetryBuilder
Owned sync retry builder created from RetryExt::retry or crate::retry.
SyncRetryBuilderWithStats
Owned sync retry builder wrapper that returns statistics.
SyncRetryWithStats
Sync retry execution-with-stats object created by .with_stats() on SyncRetry.