Expand description
§mettle
Retry for Rust, answered end to end: how long to wait, when to stop waiting on one attempt, when to give up, and what to report when it’s over. Every policy decision is a pure function of an injected clock, so a thirty second budget is testable in microseconds with no real time passing.
retry() (async) and blocking::retry() (sync) share one decision core, so the two decide
alike. Operations are FnMut() -> Fut factories rather than values, so nothing you retry has to
be Clone, Send, or 'static.
§Quickstart
Retry an async operation with sensible defaults (exponential backoff, up to 3 retries):
use mettle::retry;
let value = retry(|| async { fetch().await }).await?;Override the backoff, clock, retry predicate (.when), per-attempt bound
(.attempt_timeout), or total time budget (.max_elapsed) with the builder methods, then
.await. No async runtime? The blocking twin is identical but ends in .call() instead of
.await.
Reach for .attempt_timeout whenever the operation can hang. .max_elapsed is only consulted
between attempts, so on its own it cannot stop a call that never returns; the two together are
what make a total budget enforceable.
§Jitter
A fixed schedule means every client that failed together retries together, so a service coming back up gets a synchronized wave. Two ways to spread that out, both opt-in:
use mettle::{Backoff, DecorrelatedBackoff, DecorrelatedBackoffConfig, ExponentialBackoff};
// Randomize any strategy's delays into `0 ..= delay` ("full jitter").
let spread = ExponentialBackoff::default().jittered();
// Or draw each delay from the previous one, never below `base`.
let floored = DecorrelatedBackoff::new(DecorrelatedBackoffConfig::default())?;jittered wraps any strategy, including one you wrote.
DecorrelatedBackoff is its own strategy and keeps a floor under every wait, at the cost of
never retrying sooner than base. Both seed from entropy; use
jittered_with_seed or
DecorrelatedBackoff::with_seed when a test needs the delays to repeat.
§When it fails
Both APIs fail with a RetryError<E>: the last error, plus how many attempts ran,
how long they took, and a StopReason saying which limit stopped it. During an incident that
difference is usually the whole question, since the last error alone can’t tell you whether you
burned three retries in 700 ms or a 30 s budget.
It ?s straight into Box<dyn Error> and anyhow::Error. To go back to the bare error, use
.map_err(RetryError::into_error).
§Observability
Every retry emits a tracing event on target mettle::retry at
WARN, carrying the attempt number, delay_ms, and the error. If at least one retry
happened, giving up emits one more on the same target with attempts, elapsed_ms, and
reason. Install any subscriber to see them, filter with RUST_LOG=mettle::retry=warn, or
silence with RUST_LOG=mettle=off.
Re-exports§
pub use backoff::Backoff;pub use backoff::BackoffConfigError;pub use backoff::ExponentialBackoff;pub use backoff::ExponentialBackoffConfig;pub use backoff::Jittered;pub use clock::Clock;asyncpub use error::RetryError;pub use error::StopReason;pub use retry::retry;async
Modules§
- backoff
- Backoff strategies: whether to retry, and how long to wait before each attempt.
- blocking
blocking - Blocking (synchronous) retry: the sync twin of mettle’s async
retry, without async. - clock
async - Time as an injected dependency, so retries can be tested without real delays.
- error
- What a retry hands back when it gives up: the last error plus the context you need to tell “the dependency is down” from “our timeout is too tight”.
- retry
async - Retry a fallible async operation, backing off between attempts.