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).
cargo add relentless| 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.
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().
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();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();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();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();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(())
}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);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}");
}
}| 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.
Minimum supported Rust version: 1.85.
See CONTRIBUTING.md.
For user-facing changes, see the changelog.
Licensed under either:
- MIT (LICENSE-MIT)
- Apache-2.0 (LICENSE-APACHE)