execution-policy
Closure-first, runtime-light reliability policies for any async Rust operation: retry · backoff · jitter · attempt/total timeouts · circuit breaking · bounded concurrency · retry budgets.
A fluent, closure-first API with explicit Rust ownership, real deadlines, and
deterministic testing. It wraps any async function, job, DB call, or HTTP
client — you bring a closure, it brings the resilience. (Building a Tower
Service stack instead? Reach for tower-resilience; this crate is for
everything that isn't a Tower service.)
Quick start
Start tiny and add policy as you need it — every example below is the same builder, just with more layers.
1. Retry a flaky call. The closure is re-run on each attempt.
use ;
let policy = new
.retry
.build;
let value = policy.run.await?;
2. Only retry transient errors, and add jitter. Classification keeps you from
retrying a 404 forever.
use Duration;
use ;
let policy = new
.retry
.build;
let resp = policy.run.await?;
3. Bound how long it can take. A per-attempt cap and an overall deadline; use
execute when you want the attempt number (e.g. for a header or log).
let policy = new
.retry
.attempt_timeout
.total_timeout
.build;
let resp = policy
.execute
.await?;
4. The full picture. Add a circuit breaker and a concurrency limit, and inject your client as state so the closure borrows it cleanly across retries.
use ;
let policy = new
.retry
.attempt_timeout
.total_timeout
.circuit_breaker
.concurrency_limit
.build;
let body = policy
.execute_with
.await?;
Why it's different
- Operation factory, not a future. The closure is re-invoked per attempt, so
every retry builds a fresh request/future — no
Clonebound on your types. !Sendfriendly. The engine drives futures in place (neverspawns), so operations capturingRc/RefCellwork fine.- Classification separate from policy. Retry and circuit-breaker decisions are
independent; you can inspect
Ok-wrapped failures (e.g. an HTTP 503 insideOk(Response)). - Deterministic tests. Inject a
TestCore/ManualClock— no real sleeps, reproducible jitter and breaker windows. - Fast. ~65 ns success overhead; zero heap allocation on the success path (timers are armed lazily, only once an operation actually pends).
The four-method ergonomic gradient
policy.run.await?; // no state, no metadata
policy.run_with.await?; // state
policy.execute.await?; // attempt metadata
policy.execute_with.await?; // both
Composition order (fixed & documented)
total_timeout( concurrency( circuit_breaker( retry( attempt_timeout( operation ) ) ) ) )
The concurrency gate is acquired once per call; the breaker records one
vote per pipeline outcome; attempt_timeout bounds each try; total_timeout
bounds everything including backoff.
Errors
ExecutionError<E> implements std::error::Error (the operation error is its
source, so ? chains cleanly) and carries diagnostic context — attempts made,
elapsed time, last backoff delay, breaker state. Predicates avoid matching:
is_timeout(), is_circuit_open(), is_rejected(), is_exhausted();
into_inner() recovers the operation error.
Observability
Register a synchronous hook — zero cost when absent (events aren't even constructed without a hook):
let policy = new
.retry
.on_event
.build;
Enable the tracing feature and call .with_tracing() to bridge events to
tracing automatically.
Honor a server's retry-after hint
Some servers tell you exactly how long to wait — HTTP Retry-After, gRPC
RetryInfo, database backpressure headers, queue throttle responses. Pass a
closure to .retry_after(f) and it acts as a floor on the next backoff
delay: the engine uses max(backoff, hint). The hint is still capped by
max_backoff (if set), and if honoring it would overshoot the total_timeout
budget, the engine stops rather than overshooting.
This crate has NO http/reqwest dependency. Your closure receives &E —
you extract whatever field carries the hint, and the crate stays transport-agnostic.
use Duration;
use ;
// Your error type — could be an HTTP, gRPC, DB, or queue error.
let policy = new
.retry
.total_timeout
.build;
HTTP example — parse the Retry-After header in the consumer:
// (reqwest is YOUR dependency, not this crate's)
let policy = new
.retry
.build;
The same mechanism applies equally to gRPC (RetryInfo delay), databases
(connection-pool saturation hints), and message queues (throttle backoff
directives) — any transport that embeds an explicit delay in its error type.
Recovery / fallback at the call site
The policy returns Result<T, ExecutionError<E>> — resilience ends there. Recovery (deciding what to return on failure) is business logic that belongs at the call site, not inside a reusable resilience policy; keeping it out via or_else also gives you full call context and keeps the policy pure.
let value = policy
.run
.await
.or_else
.or_else
.or_else
.or_else?;
Links run in order; the first Ok wins — native Rust, no special API. Each closure can discriminate via e.is_circuit_open() / e.is_timeout() / e.is_exhausted() / e.into_inner().
HTTP reqwest + retry-after + cache recovery
execution-policy has no http/reqwest dependency — header parsing lives in your crate. Parse the Retry-After header into your error type; .retry_after(f) honors it as a floor on the next backoff delay inside the policy. Cache recovery is a plain .or_else(...) after policy.run(...).
use Duration;
use ;
use RETRY_AFTER;
// Your error type wraps reqwest and carries the parsed hint.
// In your HTTP layer: parse the header before returning the error.
async
let policy = new
.retry
.total_timeout
.build;
// Recovery is call-site composition — serve from cache while the upstream is throttling or down.
let body = policy
.run
.await
.or_else?;
Retry budgets
Bound retry storms across calls with a shared token bucket:
use RetryBudget;
let budget = standard; // 20% retry ratio, burst 10
let policy = new
.retry
.build;
Features
| feature | default | enables |
|---|---|---|
tokio |
✅ | TokioCore / DefaultCore (production timers) |
test-util |
✅ | TestCore / ManualClock (no extra deps) |
tracing |
.with_tracing() event bridge |
test-util is default-on so tests/benches/examples work out of the box — it pulls
in no dependencies. For a lean production build:
= { = "*", = false, = ["tokio"] }
The core even compiles with --no-default-features (no runtime); supply your own
Core to run anywhere.
Cancellation
A timeout drops the operation future — it does not abort remote or blocking
work the operation started. The engine is built around a select!-style seam, so
cooperative CancellationToken support can be added without breaking the API.
Design — guardrails, what you own, and non-goals
Guardrails (what the crate protects you from)
- Bounded retries —
max_attempts+total_timeout+ retry budgets (shared token bucket) ensure a failing dependency can't trigger a retry storm. - Jitter (
Jitter::Full/Jitter::Equal) spreads retries across callers, avoiding synchronized thundering-herd bursts. - Circuit breaker sheds load off a failing dependency and recovers automatically via half-open probing.
retry_afterhonors server-provided backpressure (HTTPRetry-After, gRPCRetryInfo, queue throttle headers) as a floor on the next backoff delay.- Typed
ExecutionError<E>preserves the failure signal — timeout / circuit-open / rejected / exhausted — and never collapses it to a generic string. Predicates (is_timeout(),is_circuit_open(), etc.) let you discriminate without matching.
What you own (deliberately NOT in the policy)
- Idempotency — the policy never assumes a call is safe to retry. YOU classify what's retryable via
.when(...). This is the single most important footgun the crate refuses to guess for you: retrying a non-idempotent write causes duplicates. - Recovery / fallback — handled at the call site with
.or_else(...)on the returnedResult<T, ExecutionError<E>>, not a policy knob. Keeping business logic out keeps the policy a reusable resilience primitive. See Recovery / fallback at the call site for the composable.or_elsepattern. - Transport — HTTP/gRPC/queue header parsing lives in your crate. The crate has no
http/reqwestdependency; your closure receives&Eand returns aDuration.
Non-goals & possible future directions
These are deliberately out of scope today. Each note states the trigger that would make it worth adding and the rough approach — not a promise or roadmap.
- Composable strategy pipeline (Polly-v8 / Tower style) — Trigger: need for third-party custom strategies, caller-controlled exotic ordering, or multiple same-type layers (e.g. two circuit breakers). Approach: a
Strategytrait, with the existing builder kept as a facade that assembles a default pipeline — non-breaking, opt-in, no API churn for existing callers. Why not now: the builder already covers ~90 % of real-world needs including both timeout orderings (attempt_timeoutinside the loop,total_timeoutas overall deadline); the remaining long-tail overlapstower/tower-resilience, which this crate deliberately is not. - Hedging (fire a speculative backup attempt for slow requests; take whichever finishes first; cancel the loser) — Trigger: idempotent reads hitting multiple replicas/backends with tail-latency (p99) SLOs. Approach: concurrent attempts + cancellation, gated to idempotent ops only via the
.when(...)predicate. Why not now: dangerous for non-idempotent ops, and pointless against a single backend (it just doubles load rather than improving latency). - Cross-hop deadline propagation (thread the remaining time budget across service calls, like
grpc-timeout) — Trigger: multi-hop synchronous call chains where A→B→C each consume part of an inbound deadline. Approach: expose aremaining()accessor on the policy so the consumer seedstotal_timeoutfrom an inbound deadline and writes the outbound deadline header; header read/write stays in the consumer/transport layer and the crate remains transport-agnostic. Why not now: single-hop usage doesn't need it, and deadline propagation is a transport/context concern rather than a per-operation policy concern.
License
BSD-3-Clause.