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
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.4.0] - 2026-08-09

Retry now reports why it gave up, not just what failed last. Breaking.

### Added
- `RetryError<E>`, with `error()`, `into_error()`, `attempts()`, `elapsed()` and `stop_reason()`.
- `StopReason`: `RetriesExhausted`, `NotRetryable`, `MaxElapsed`, plus `as_str()` for metric labels.

### Changed
- **Breaking:** `retry(..).await` and `blocking::retry(..).call()` fail with `RetryError<E>` instead
of `E`.
- **Breaking:** giving up emits one more `tracing` event on `mettle::retry`, carrying `attempts`,
`elapsed_ms` and `reason`. It fires only when at least one retry happened, so a `.when(..)` filter
rejecting the first error stays as quiet as it was.
- The async driver starts its clock on the first poll rather than at `.into_future()`, matching the
blocking driver. A future parked before its first poll no longer bills that time to the operation.

### Upgrading

Wherever you name the error type:

```diff
-let value: Result<T, MyError> = retry(op).await;
+let value: Result<T, RetryError<MyError>> = retry(op).await;
```

`?` into `Box<dyn Error>` or `anyhow::Error` keeps working, and now covers error types it didn't
before: `String`, `Box<dyn Error>` and `anyhow::Error` all satisfy the new bound. To go back to the
bare error and keep an existing signature, add `.map_err(RetryError::into_error)`.

Matching on the error goes through an accessor, so `match e` becomes `match e.error()`.

Tests that asserted on the whole `Result` need the error unwrapped. `RetryError` is deliberately
not `PartialEq`, and could not usefully be: its fields are private with no public constructor, so
there is no way to build the right-hand side to compare against.

```diff
-assert_eq!(result, Err(MyError::Timeout));
+assert_eq!(*result.unwrap_err().error(), MyError::Timeout);
-assert_eq!(result, Ok(42));
+assert_eq!(result.unwrap(), 42);
```

`RetryError` deliberately has no `source()`. Why, and why its `Error` impl is bounded on
`E: Debug + Display` rather than `E: Error`:
[ADR006](https://github.com/azeemshaik025/mettle/blob/main/docs/adr/ADR006.md).

## [0.3.0] - 2026-08-09

Adds jitter. Purely additive apart from one collision noted under Upgrading.
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,12 +1,12 @@
[package]
name = "mettle"
version = "0.3.0"
version = "0.4.0"
edition = "2024"
rust-version = "1.85"
description = "A resilience toolkit for Rust: async and blocking retries with configurable backoff and jitter."
readme = "README.md"
license = "MIT OR Apache-2.0"
keywords = ["retry", "backoff", "resilience", "async", "tokio"]
keywords = ["retry", "backoff", "jitter", "resilience", "tokio"]
categories = ["asynchronous", "network-programming"]
repository = "https://github.com/azeemshaik025/mettle"
documentation = "https://docs.rs/mettle"
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,22 @@ retrying sooner than `base`, so a dependency that frees up early isn't picked up
Both seed from entropy by default and take a fixed seed (`with_seed`) when you want a test to
replay the same delays.

When a retry gives up you get a `RetryError`, which says what stopped it:

```rust
match retry(|| async { fetch(&url).await }).await {
Ok(body) => body,
Err(e) => {
// "max_elapsed after 5 attempts in 29.4s: connection refused"
tracing::error!("{} after {} attempts in {:?}: {}",
e.stop_reason().as_str(), e.attempts(), e.elapsed(), e.error());
return Err(e.into()); // ?-able into Box<dyn Error> / anyhow
}
}
```

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

## Tools

Each tool comes with a runnable example. Start there:
Expand Down
92 changes: 92 additions & 0 deletions docs/adr/ADR006.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# ADR006: `RetryError<E>`, and the bound that decides who can use `?`

**Status:** Accepted

## Context

Through 0.2.0, a retry that gave up handed back the last error and nothing else. That error can't
answer the question you actually have at 3am: did we exhaust three retries in 700 ms, or did we
spend a 30 s budget, or did the `.when` predicate reject the very first error and we never retried
at all? Those are three different incidents and they looked identical.

Fixing that means changing the error type of the only two functions anyone calls, so it's the kind
of change 0.x exists for and we only get to make cheaply once.

## Decisions

**1. Always wrap. `retry` and `blocking::retry` fail with `RetryError<E>`.**
Not an opt-in second terminal like `.detailed()`. That alternative adds a parallel type, a second
documented path that never goes away, and a builder method whose position relative to the other
four matters. It also doesn't save anything: there's one state machine, so it either always
measures at the give-up point or grows a runtime branch to decide whether to bother.

The escape hatch is one call. `.map_err(RetryError::into_error)` gets you exactly the 0.2.0
behaviour, and that sentence is in the CHANGELOG, both module docs, and the `into_error` rustdoc.

**2. `impl Error for RetryError<E> where E: Debug + Display`, and no `source()`.**
This is the consequential one, and it's binary. The alternative is
`where E: Error + 'static` plus a `source()` that returns the inner error. You cannot have both:
they overlap, and the compiler rejects the pair with E0119.

We took `Debug + Display` because it covers strictly more error types:

| `E` | `Error + 'static` | `Debug + Display` |
|-----|-------------------|-------------------|
| `io::Error`, `thiserror` enums | works | works |
| `String`, `&str` | no | works |
| `Box<dyn Error>` | no | works |
| `anyhow::Error` | no | works |

`anyhow::Error` does not implement `std::error::Error`, and retrying an `anyhow::Result` is about
as common as application code gets. Choosing `Error + 'static` would mean that code can't use `?`
at all.

What we give up is the error chain. Anything that walks `source()` (anyhow's `{:#}`,
`std::error::Report`, tracing-error) sees one link where it might have seen two. The inner error's
text is still there, because `Display` includes it, and `downcast_ref::<RetryError<E>>()` recovers
the whole structure including the attempt count. We think the text is what people read and the
chain is what libraries walk, and the population that can't compile at all is worse off than the
population with a flatter chain.

Reversing this later is breaking in both directions, so it's written down here rather than left as
an accident of whichever impl got typed first.

**3. `Display` is bounded on `E: Display` and includes the inner error.**
The alternative, an unbounded `impl<E> Display` printing only the stop reason, would mean
`eprintln!("{e}")` never says what actually failed. For `&str` and `String` errors there's no chain
to recover it from, so that text would be gone. Widening a bounded impl to unbounded later is
non-breaking; narrowing is not, so the bounded version is also the reversible direction.

**4. No `PartialEq`.**
Downstream can't construct a `RetryError` (private fields, no public constructor), so the
right-hand side of an `assert_eq!` is unbuildable anyway. Where it would work it compares
`elapsed`, which is wall-clock. Adding it later is non-breaking.

**5. The clock starts at the first poll, not at `.into_future()`.**
The async driver used to sample `start` when the builder was converted. The blocking driver samples
at the first attempt. That difference was invisible while nothing exposed elapsed time; the moment
`elapsed()` became public it would have been two drivers reporting different numbers for the same
work, which ADR001 decision 3 exists to prevent. It also stops a future that sits parked in a
`FuturesUnordered` from billing the parking time to the operation.

**6. `attempts()` counts attempts, not retries.**
Internally the counter is retries performed. The public number is one more than that, because even
an error rejected by `.when` on the first try means the operation ran once. Returning the raw
counter would compile and quietly report `0` attempts for an operation that demonstrably ran, so
both drivers go through one shared `give_up` and both pin `attempts() == 1` on that path in tests.

**7. One give-up event, only when a retry happened.**
Giving up emits a single `WARN` on `mettle::retry` with `attempts`, `elapsed_ms`, and `reason`. It
carries no error field: the caller now holds the error, and the last retry event already logged it.
The gate matters. Without it, a `.when(|e| e.is_transient())` in front of an HTTP client would emit
a warning for every 404, on a path that is silent today.

## Consequences

Every caller changes their error type, and `?` into a concrete `E` needs
`.map_err(RetryError::into_error)`. In exchange, `?` into `Box<dyn Error>` and `anyhow::Error` now
works where it didn't in 0.2.0, and a failed retry can be reported without guessing.

We're committed to the `Debug + Display` bound and to the absence of `source()`. Both are one-way
after 0.3.0. ADR003 named the `mettle::retry` target as something people build against, and that
target now carries two message shapes, so anyone counting events on it sees a change.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ changes, edit its file and say what changed and why.
| [003](ADR003.md) | Dependencies and Cargo features | Accepted |
| [004](ADR004.md) | Jitter, a fourth dependency, and `Clone` | Accepted |
| [005](ADR005.md) | Making the tested path as usable as the production path | Accepted |
| [006](ADR006.md) | `RetryError<E>` and the `Error` bound | Accepted |
2 changes: 1 addition & 1 deletion src/blocking/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
//! # fn fetch() -> Result<u32, std::io::Error> { Ok(1) }
//! let value = mettle::blocking::retry(fetch).call()?;
//! # let _ = value;
//! # Ok::<(), std::io::Error>(())
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```

mod clock;
Expand Down
Loading
Loading