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());

§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}");
    }
}

§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
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.
sleep
Async sleep abstractions used by the retry engine between attempts.
stop
Stop trait and built-in stop strategies.
wait
Wait trait and built-in wait strategies.

Structs§

AsyncRetry
Async retry execution object.
AsyncRetryBuilder
Owned async retry builder created from AsyncRetryExt::retry_async.
AsyncRetryBuilderWithStats
Owned async retry builder wrapper that returns statistics.
AsyncRetryWithStats
Async retry execution wrapper that returns statistics.
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.
SyncRetry
Sync retry execution object.
SyncRetryBuilder
Owned sync retry builder created from RetryExt::retry.
SyncRetryBuilderWithStats
Owned sync retry builder wrapper that returns statistics.
SyncRetryWithStats
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§

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