Skip to main content

Crate mettle

Crate mettle 

Source
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::DecorrelatedBackoff;
pub use backoff::DecorrelatedBackoffConfig;
pub use backoff::ExponentialBackoff;
pub use backoff::ExponentialBackoffConfig;
pub use backoff::Jittered;
pub use clock::Clock;async
pub 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.
blockingblocking
Blocking (synchronous) retry: the sync twin of mettle’s async retry, without async.
clockasync
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”.
retryasync
Retry a fallible async operation, backing off between attempts.