resilience 1.0.2
resilience: ^1.0.2 copied to clipboard
Circuit breaker, bulkhead, retry with backoff, timeout, and rate limiter policies to keep Dart async calls resilient when a dependency fails or throttles. Zero dependencies.
resilience #

Retry with backoff and jitter, circuit breaker, timeout, rate limiter, and bulkhead policies for reliable async operations. Zero dependencies.
Network calls fail, dependencies slow down, and third-party APIs throttle. This package provides the standard answers to those problems as small, composable policy objects with one shared interface:
abstract interface class Policy {
Future<T> execute<T>(Future<T> Function() action);
}
Every policy wraps an async action. Policies compose through
ResiliencePipeline, and the whole package has no dependencies outside the
Dart SDK.
Policies #
| Policy | What it does |
|---|---|
Retry |
Runs the action again after a failure, with configurable backoff and jitter |
CircuitBreaker |
Fails fast after repeated failures so a broken dependency can recover |
Timeout |
Fails the call when the action takes too long |
RateLimiter |
Limits how often actions start, using a token bucket |
Bulkhead |
Limits how many actions run concurrently |
Hedge |
Starts a second copy of a slow call and takes the first to finish |
ResiliencePipeline |
Composes any of the above into one policy |
withFallback |
Returns a substitute value when everything above still failed |
Install #
dart pub add resilience
Retry #
import 'dart:async';
import 'package:resilience/resilience.dart';
final retry = Retry(
maxAttempts: 4,
backoff: Backoff.exponential(
initial: Duration(milliseconds: 200),
factor: 2,
max: Duration(seconds: 30),
jitter: 0.5,
),
retryIf: (error) => error is TimeoutException,
onRetry: (event) => log('attempt ${event.attempt} failed: ${event.error}'),
);
final data = await retry.execute(() => fetchData());
maxAttempts counts the first attempt, so maxAttempts: 4 means one
initial call plus up to three retries. When retryIf is omitted, every
error is retried except CircuitOpenException (see below). The last attempt
rethrows the original error.
Backoff strategies:
Backoff.none(): retry immediately.Backoff.fixed(duration): the same delay every time.Backoff.exponential(...):initial * factor^(attempt - 1), capped atmax.jitterbetween 0 and 1 randomizes each delay within[base * (1 - jitter), base]so simultaneous clients do not retry in lockstep.
Backoff is an interface, so a custom schedule is one small class away.
Circuit breaker #
A circuit breaker stops calling a dependency that keeps failing, then probes it once in a while until it recovers. Create one breaker per dependency and share it between callers; the state lives in the instance.
final breaker = CircuitBreaker(
failureThreshold: 5,
resetTimeout: Duration(seconds: 30),
onStateChange: (state) => log('search backend circuit: $state'),
);
final results = await breaker.execute(() => searchBackend(query));
After failureThreshold consecutive failures the breaker opens and every
call throws CircuitOpenException without running the action. The
exception carries retryAfter, the time left until the breaker allows a
trial. After resetTimeout the breaker admits exactly one trial call:
success closes the circuit, failure reopens it.
countAs filters which errors count toward the threshold. Errors it
rejects are rethrown but do not affect the breaker state:
final breaker = CircuitBreaker(
countAs: (error) => error is! ArgumentError,
);
Timeout #
const timeout = Timeout(Duration(seconds: 2));
final page = await timeout.execute(() => fetchPage(url));
Throws TimeoutException when the action takes longer than the given
duration.
One honest caveat: Dart futures cannot be cancelled. When the timeout
fires, the underlying action keeps running and its eventual result or
error is discarded. Timeout bounds how long the caller waits, not how
long the work runs. If the action holds a scarce resource, pair it with a
Bulkhead or handle cleanup inside the action.
Rate limiter #
A token bucket. The bucket holds maxPermits tokens and refills at a
steady rate of maxPermits per per (one token every per / maxPermits).
Each call consumes one token before starting, so a full bucket allows a
short burst and sustained load proceeds at the refill rate.
final limiter = RateLimiter(
maxPermits: 10,
per: Duration(seconds: 1),
maxQueueLength: 100,
);
final response = await limiter.execute(() => callThirdPartyApi());
When no token is available the call waits in a FIFO queue. If the queue
already holds maxQueueLength calls, the new call fails with
RateLimitExceededException instead of waiting. Leave maxQueueLength
null for an unbounded queue, or set it to 0 to fail immediately whenever
no token is available.
Bulkhead #
A concurrency limit. At most maxConcurrent actions run at once; up to
maxQueued more wait in FIFO order, and beyond that calls fail with
BulkheadRejectedException.
final bulkhead = Bulkhead(maxConcurrent: 4, maxQueued: 16);
final report = await bulkhead.execute(() => renderReport(id));
A bulkhead keeps one slow dependency from soaking up every worker in the process: the dependency saturates its own slots and the rest of the app keeps running.
Hedging #
Retrying does not help a call that is merely slow: a retry only starts once the
slow attempt has failed or timed out, and by then the latency is already spent.
Hedge starts another attempt while the first is still in flight and takes
whichever finishes first, which is what trims a p99 caused by one stalled
connection or an unlucky pause.
final hedge = Hedge(delay: Duration(milliseconds: 200));
final response = await hedge.execute(() => client.get(url));
The first attempt starts immediately; if it has not finished after delay,
another starts alongside it, up to maxAttempts. A failed attempt brings the
next one forward instead of waiting out the delay. Losers are ignored, though
they do run to completion, since Dart cannot cancel a future.
Only hedge what is safe to run twice. A hedged POST that creates an order can
create two, so use it on reads or on writes an idempotency key makes safe. It
also multiplies load on a backend that is slow because it is overloaded, so set
delay near your p95 rather than your median.
Falling back #
The policies above decide how hard to try. withFallback decides what to show
when trying did not work: the last cached response, an empty list, a default.
final pipeline = ResiliencePipeline([retry, breaker, timeout]);
final rates = await withFallback(
pipeline,
() => api.fetchRates(),
fallback: (error, stackTrace) => cache.lastRates,
shouldHandle: (e) => e is! ArgumentError, // optional
);
It is a function rather than a policy on purpose. A fallback swallows the error, so it belongs outside everything else: inside a retry, the retry sees a success and never runs again; inside a circuit breaker, the breaker never learns the call is failing. Taking the policy as an argument leaves the outermost position as the only one available, and keeps the substitute typed to the action's own result.
Composing policies #
ResiliencePipeline wraps policies from the outside in; the first policy
in the list is the outermost.
final pipeline = ResiliencePipeline([
Retry(maxAttempts: 3, backoff: Backoff.exponential(jitter: 0.5)),
breaker,
Timeout(Duration(seconds: 2)),
limiter,
]);
final user = await pipeline.execute(() => fetchUser(id));
This reads as: the retry wraps the breaker, which wraps the timeout, which wraps the rate limiter, which gates the action. Order matters:
-
Retry outside the breaker: once the breaker opens, the retry stops. A
CircuitOpenExceptionis thrown without the action being called, so further attempts would spend the budget, and sleep through the backoff, on calls that are never made. Only time reopens a circuit.If you want the opposite, supply
retryIfand let the exception through:Retry( maxAttempts: 3, backoff: Backoff.fixed(const Duration(seconds: 20)), retryIf: (error) => true, )That is worth doing only when the backoff can outlast the breaker's
resetTimeout(30 s by default), so a later attempt arrives after the circuit is willing to half-open. With the usual sub-second backoffs it cannot, which is why it is not the default. -
Breaker outside the retry: one fully exhausted retry counts as a single failure toward opening the circuit.
To put a total time budget on the whole retried operation, place a
Timeout outside the Retry, as in
ResiliencePipeline([Timeout(total), Retry(...), ...]). When the breaker
wraps a Timeout, each TimeoutException counts as a failure toward
opening the circuit unless countAs filters it out.
A pipeline is itself a Policy, so pipelines can be nested and shared.
See example/resilience_example.dart for a complete program.
Testability #
The parts that involve randomness or time accept injectable seams:
Backoff.exponential takes a Random, and CircuitBreaker takes a
now function. Retry delays, rate limiter refills, and timeouts are
driven by timers, so they work with package:fake_async out of the box.
Design notes #
- Zero runtime dependencies; only the Dart SDK.
- Policies are plain objects.
Retry,Timeout, and backoffs are stateless and reusable anywhere.CircuitBreaker,RateLimiter, andBulkheadare stateful by design: create one per protected resource and share it. - No global registry, no configuration files, no code generation.
