relentless
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
RetryPolicycaptures your retry rules and gets shared across multiple call sites — no duplicated builder chains. - Strategy composition, where
wait::fixed(50ms) + wait::exponential(100ms)andstop::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
Feature flags
| Flag | Purpose |
|---|---|
std (default) |
std::thread::sleep fallback, Instant elapsed clock, std::error::Error on RetryError |
alloc |
Boxed policies, closure elapsed clocks |
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 |
test-util |
test_util::VirtualClock — deterministic testing of retry behavior without real sleeps |
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 RetryExt;
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 Duration;
use ;
let body = retry
.when
.wait
.stop
.timeout
.call;
3) Reuse a policy across call sites
RetryPolicy captures retry rules once. Compose wait strategies with + and
stop strategies with | or &.
use Duration;
use ;
let policy = new
.wait
.stop;
// Same policy, different operations.
let health = policy.retry.call;
let invoice = policy.retry.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 ;
let result = new
.until
.retry
.call;
To also retry selected errors during polling, use predicate::result:
use ;
// Retry until Done or Fatal; keep going on Pending or Retryable.
let result = new
.until
.retry
.call;
5) Async retry
Pass an async sleep adapter — here via the tokio-sleep feature.
use retry_async;
async
async
More
Full inline code for these lives in the API docs,
with runnable versions in examples/:
- Hooks & stats — observe the retry lifecycle for logging or metrics with
.before_attempt/.after_attempt, and collect aRetryStatssummary via.with_stats(). (hooks-and-stats.rs) - Error handling — on failure you get a
RetryError:Exhausted { last }when the stop strategy fired (lastis the final attempt's fullResult<T, E>— polling can exhaust while the last outcome was stillOk), orRejected { last }when a predicate deemed the error non-retryable (lastis that error itself). - Deterministic testing — the
test-utilfeature'sVirtualClockasserts the exact backoff schedule with zero wall-clock time spent, so timeout and backoff tests stay fast and non-flaky. (testing-with-virtual-clock.rs) - Cancellation — there is no built-in cancel primitive; the loop observes
the cancellation your environment already provides (a dropped future, an
AtomicBool,.timeout(...)) at attempt boundaries. (sync-cancel.rs,async-cancel.rs)
How the builders fit together
The full API surface — every strategy, predicate, and type — lives on docs.rs. Two things worth knowing up front:
Builder chains read best in this order: when/until -> wait -> stop
-> sleep -> hooks -> stats -> call. That order is a reading convention, not a
compiler contract — the types enforce only three rules: strategy overrides
(when/until/wait/stop) exist only on builders that own their policy
(below), everything is configured before .with_stats(), and an async chain
needs .sleep(...) before .call().
Where you start decides what you can override. The free-function and
extension-trait builders own their policy, so they accept the strategy overrides
when/until/wait/stop. An execution started from a shared RetryPolicy
(policy.retry(...)) keeps that policy's strategies fixed and accepts only
sleep, hooks, timing, and stats methods.
MSRV
Minimum supported Rust version: 1.85.
Contributing
See CONTRIBUTING.md.
Release notes
For user-facing changes, see the changelog.
License
Licensed under either:
- MIT (LICENSE-MIT)
- Apache-2.0 (LICENSE-APACHE)