Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.5.0] - 2026-08-16

Retry can finally stop an attempt that hangs. Breaking.

### Added
- `Retry::attempt_timeout(duration, on_timeout)`. Bounds a single attempt, so an operation that
hangs is finally stopped. When it fires the in-flight future is dropped and the attempt is
treated as a failure, feeding the normal backoff and the normal `.when(..)` predicate.
`on_timeout` supplies the error to report, because the operation never returned one.

The wait runs on the injected `Clock`, not on Tokio directly, so a timeout is testable on a mock
clock with no real time. `tokio::time::timeout` cannot be.
[ADR007](https://github.com/azeemshaik025/mettle/blob/main/docs/adr/ADR007.md) covers why the
error comes from a closure rather than making `RetryError::error()` an `Option`, and why there is
no blocking equivalent.

### Fixed
- `max_elapsed` documented honestly. It is checked *between* attempts, so on its own it never
bounded an operation that hangs, while the README said "give up after ~30s total". A future that
is never ready gave the budget nothing to act on. Pairing it with `attempt_timeout` is what makes
the budget enforceable; the blocking twin says plainly that it has no equivalent and points at
the call's own timeout setting instead.

### Changed
- **Breaking:** `Retry` and `RetryFuture` take one more type parameter, for the on-timeout handler.
Only affects code that names those types; `retry(..)` and every builder method are unchanged.
- The crate description and docs now say what mettle is — retry, answered end to end — rather than
"a resilience toolkit", and no longer claim that timeout and circuit breaking are planned. Timeout
shipped here; the circuit breaker was built and deliberately not shipped. Scope, including what
has been refused and why, is in
[docs/ROADMAP.md](https://github.com/azeemshaik025/mettle/blob/main/docs/ROADMAP.md).

### Upgrading

Nothing to do unless you *name* `Retry` or `RetryFuture`, which mostly means storing one in a
struct field or writing a function that returns one. `retry(..)` and every builder method are
unchanged, so the common inline use compiles as-is.

```diff
-fn build() -> Retry<F, ExponentialBackoff, TokioClock, fn(&E) -> bool> {
+fn build() -> Retry<F, ExponentialBackoff, TokioClock, fn(&E) -> bool, fn() -> E> {
```

The new parameter is the on-timeout handler. When no timeout is configured it is the function
pointer `fn() -> E` that `retry(..)` seeds, and it is never called. A default (`Q = NoTimeout`) was
tried and does not work: `NoTimeout` cannot implement `Fn() -> E`, so the no-timeout case would
need a second `IntoFuture` impl and the two would be seen as potentially overlapping.
[ADR007](https://github.com/azeemshaik025/mettle/blob/main/docs/adr/ADR007.md) has the detail.

## [0.4.0] - 2026-08-09

Retry now reports why it gave up, not just what failed last. Breaking.
Expand Down
28 changes: 25 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,37 @@ cargo fmt --all # format
cargo clippy --all-targets --all-features -- -D warnings
```

Keep the whole feature matrix green:
Keep the whole feature matrix green. CI runs `test`, `clippy`, `docs` and `msrv` against each of
these four configurations, so a change that only builds under `--all-features` still fails:

```sh
cargo test --all-features
cargo test # defaults
cargo test --no-default-features --features async
cargo test --no-default-features --features blocking
```

MSRV is 1.85. CI runs all of the above plus a docs build (`-D warnings`) and a
semver-compatibility check, so run them locally before opening a PR.
MSRV is 1.85, and it applies to tests and examples too:

```sh
cargo +1.85.0 check --all-features --all-targets
```

The two checks that only CI used to catch, and that are worth running before you open a PR:

```sh
RUSTDOCFLAGS='-D warnings' cargo doc --all-features --no-deps # broken intra-doc links
cargo semver-checks # cargo install cargo-semver-checks
```

`cargo doc` is the only thing that catches a broken intra-doc link; tests, clippy and MSRV all pass
straight through them.

**If your change is breaking, bump the version in the same branch.** `cargo semver-checks` runs on
the pull request and compares your manifest against the published crate, so a breaking change left
at the current version fails CI no matter how good it is. In 0.x that means a minor bump: 0.4.0 to
0.5.0. Finalize the `CHANGELOG.md` heading with a date in the same commit and leave an empty
`## [Unreleased]` above it.

## Licensing

Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
[package]
name = "mettle"
version = "0.4.0"
version = "0.5.0"
edition = "2024"
rust-version = "1.85"
description = "A resilience toolkit for Rust: async and blocking retries with configurable backoff and jitter."
description = "Retry for Rust, async and blocking: backoff, jitter, per-attempt timeouts, and a clock you can mock."
readme = "README.md"
license = "MIT OR Apache-2.0"
keywords = ["retry", "backoff", "jitter", "resilience", "tokio"]
Expand Down
44 changes: 33 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
[![CI](https://github.com/azeemshaik025/mettle/actions/workflows/ci.yml/badge.svg)](https://github.com/azeemshaik025/mettle/actions/workflows/ci.yml)
[![license](https://img.shields.io/crates/l/mettle.svg)](#license)

**A resilience toolkit for Rust.**
**Retry for Rust, answered end to end.**

Composable, testable primitives for handling failure. [Documentation](https://docs.rs/mettle).
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 30-second budget is
testable in microseconds with no real time passing. [Documentation](https://docs.rs/mettle).

## Install

Expand All @@ -25,16 +27,27 @@ cargo add mettle --no-default-features --features blocking

```rust
use mettle::retry;
use std::time::Duration;

// Retry with sensible defaults (exponential backoff, up to 3 retries),
// then override only what you need.
// Sensible defaults: exponential backoff, up to 3 retries.
let body = retry(|| async { fetch(&url).await }).await?;
```

Then override only what you need:

```rust
let body = retry(|| async { fetch(&url).await })
.when(|e: &FetchError| e.is_transient()) // skip permanent errors
.max_elapsed(Duration::from_secs(30)) // give up after ~30s total
.when(|e: &FetchError| e.is_transient()) // skip permanent errors
.attempt_timeout(Duration::from_secs(5), || FetchError::Timeout)
.max_elapsed(Duration::from_secs(30))
.await?;
```

`attempt_timeout` bounds a single try, dropping the in-flight future and feeding the timeout into
the normal backoff. Reach for it whenever the call can hang: `max_elapsed` is only consulted
*between* attempts, so on its own it cannot stop a call that never returns. Its second argument is
the error to report, since a timed-out attempt never returned one of its own — for `io::Error` that
is `|| ErrorKind::TimedOut.into()`.

No async runtime? The blocking twin is identical but ends in `.call()` instead of `.await`.

Retrying on a fixed schedule means every client that failed together retries together, so a service
Expand Down Expand Up @@ -74,15 +87,24 @@ match retry(|| async { fetch(&url).await }).await {

Only want the underlying error? `.map_err(RetryError::into_error)`.

## Tools

Each tool comes with a runnable example. Start there:
## Examples

- **retry**: async [examples/retry.rs](https://github.com/azeemshaik025/mettle/blob/main/examples/retry.rs) · blocking [examples/blocking_retry.rs](https://github.com/azeemshaik025/mettle/blob/main/examples/blocking_retry.rs)
Runnable, and the fastest way in:
[examples/retry.rs](https://github.com/azeemshaik025/mettle/blob/main/examples/retry.rs) (async) ·
[examples/blocking_retry.rs](https://github.com/azeemshaik025/mettle/blob/main/examples/blocking_retry.rs)
(blocking).

Retries emit `tracing` events out of the box (target `mettle::retry`). Install any subscriber
(e.g. `tracing_subscriber::fmt::init()`) to see them.

## Scope

mettle does retry, and does it completely, rather than being a shallow toolkit of five tools. The
test for anything new is whether it's about a failed call; that's what keeps bulkheads, rate
limiting and caching out. What's planned and what's been refused, with the reasoning, is in
[docs/ROADMAP.md](https://github.com/azeemshaik025/mettle/blob/main/docs/ROADMAP.md); design
decisions are in [docs/adr](https://github.com/azeemshaik025/mettle/tree/main/docs/adr).

## Status

v0.x, with async (Tokio) and blocking APIs. Expect breaking changes before 1.0.
Expand Down
126 changes: 126 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Roadmap and scope

What mettle intends to build, and what it refuses to. The refusals matter more than the plans: a
crate with an edge is worth more than a crate with everything, and every feature costs a feature
matrix, a blocking twin, and an ADR forever.

This file is the record. If something isn't here, it hasn't been decided.

## What mettle is

**Retry, solved completely, and the only one you can test exactly without sleeping.**

Not a toolkit of five shallow tools. One problem, answered end to end: how long to wait, when to
stop waiting on one try, when the fleet should stop retrying, when to try again before the first
finishes, and what to report when it's over. Every policy decision is a pure function of an injected
time source, so you can test your configuration at its boundaries with no real clock.

That gives a test for every future request: **is this about a failed call?** If not, it's out. That
test is what refused most of the list below, and it's the reason this file exists.

The second, smaller claim is shape. Operations are `FnMut() -> Fut` factories, so nothing needs
`Clone`, `Send`, or `'static`. That's why `tower::retry` doesn't work for tonic users whose
`http::Request` isn't `Clone`.

## Shipped

| | |
|------|-------|
| retry, async and blocking twins over one decision core | 0.1 |
| exponential backoff, validated config, saturating arithmetic | 0.1 |
| jitter (full) and `DecorrelatedBackoff` | 0.3.0 |
| `RetryError<E>` with attempts, elapsed, and why it stopped | 0.4.0 |
| `attempt_timeout`, so a hung attempt is finally bounded | 0.5.0 |

## Planned

In order. Each is a prerequisite for the ones under it.

**1. Retry budget.**
A shared, rate-limited allowance for retries, so a partial outage can't turn into a retry storm.
Universal outside Rust (gRPC `retryThrottling`, Envoy `budget_percent`, Linkerd `retryRatio`,
Finagle `RetryBudget`, AWS retry quotas) and effectively absent inside it: the only working
implementation is buried in `aws-smithy-runtime` behind about twenty crates.

This is the one the competition structurally cannot copy. A budget needs a handle shared across
calls; `backon`, `tokio-retry`, `tryhard` and `again` all hand out per-call values. Verified against
`backon` (25M downloads/90d): no budget, no hedging, no per-attempt timeout.

Two decisions belong in the ADR before any code. Use a rate-with-TTL budget rather than the timeless
token bucket, because "does the budget refill after sixty idle seconds" is then a hand-fed-`Instant`
test nobody else in Rust can write, and because a budget that only refills on traffic locks out a
low-QPS caller forever. And name the debit ordering: `when(err)` runs before the backoff is drawn,
so a naive implementation debits for a retry that may never happen.

**2. `cargo-mutants` in CI.**
Not a feature. The worst bugs this crate has had (the cleared window, the generation token, the
half-open livelock, the mock clock advancing on creation) were all mutation-detectable and all found
by hand. Do this before hedging.

**3. Hedging, one backup request.**
Send a backup when the first request passes a latency threshold, take whichever answers first. Not a
separate tool: it's speculative retry. Weak incumbents — `tower::hedge` has had no functional change
since 2022, and the standalone crates have four-figure lifetime downloads.

Strictly after the budget. Hedging at a fixed delay doubles load on a degraded backend exactly when
it's degraded, which is why gRPC gates hedging on `retryThrottling` and Finagle builds its backup
requests on `RetryBudget`.

Only ever one backup. `#![forbid(unsafe_code)]` means there's no safe projection from
`Pin<&mut [Option<Fut>; N]>` to element *i*, and boxing would violate ADR001.

## Refused

Recorded so nobody relitigates them. Each can be revisited if a real user asks, but the default is
no.

- **Circuit breaker.** Built, tested, and not shipped — see below. The one refusal we reached by
building the thing.
- **Bulkhead.** Reads no clock, so it exercises none of what this crate is for, and
`Semaphore::try_acquire` is five lines. A queueing bulkhead is worse: it needs async queueing
machinery mettle doesn't have, and the blocking twin is untestable because `Condvar::wait_timeout`
has no injection point.
- **General rate limiting.** `governor` (13.6M downloads/90d) and `ratelimit` (4M) own this, and
`ratelimit` already implements the injected-clock thesis. Being third here would turn our
differentiator into table stakes.
- **Adaptive concurrency limits (AIMD, Vegas, Gradient).** The value is the published control law,
not deterministic replay. Deterministic testing proves a limiter is repeatable, not well tuned.
- **Standalone timeout.** `tokio::time::timeout` exists, and a blocking version would force
`Send + 'static` onto the caller's operation. What was genuinely missing was the *per-attempt*
bound, which shipped as `attempt_timeout` (ADR007).
- **Fallback.** `Result::or_else` already is the combinator.
- **Cache.** Owned by `moka` and `foyer`, and there's no decision logic to make pure, so the
architecture buys nothing.
- **Health checks.** A fleet concern that belongs with discovery and load balancing. mettle has no
notion of a set of endpoints.
- **Ambient deadline propagation.** The hard part is a task-local convention that survives `spawn`,
which is a runtime and ecosystem problem.
- **Graceful degradation.** A posture, not a primitive.

## The circuit breaker, in detail

It exists, on the `circuit-breaker` branch: a pure state machine, a shared wrapper, RAII permits, an
injected `Now`, 100 tests, and a half-open livelock found and fixed. It is not shipped.

The demand is roughly fiftyfold smaller than retry — about 1.2M downloads per 90 days across every
Rust breaker (`failsafe`, `recloser`, `circuit_breaker`) against about 60M for the retry crates.
Infrastructure has also absorbed much of the use case: Istio, Linkerd and Envoy break circuits at
the proxy, so anyone on a mesh already has one.

And it doesn't fit the spine. Everything else here answers "a call failed, now what". A breaker
answers "should I call at all", which is why it was the only thing needing shared mutable state, a
new trait, and a concurrency story. Shipping it would double the public surface for a tool the
evidence says few would reach for.

The work isn't wasted; it's how we found out. If a user asks, the branch is ready.

## The ceiling

One maintainer, with a commitment to deterministic tests and mutation-checked fixes. The binding
constraint isn't lines of code, it's feature matrix times twin count times ADR surface. Every
async-only feature is a documented exception to the twin rule: `attempt_timeout` is the first, and
hedging would be the second, which is as many as this crate should have.

Budget for correction too. The circuit breaker needed a fix worth roughly a fifth of its size
*after* it was otherwise complete, and that was the half-open livelock, which no per-call assertion
could have caught. Assume the same on the retry budget.
Loading
Loading