resilience 1.1.1 copy "resilience: ^1.1.1" to clipboard
resilience: ^1.1.1 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 #

resilience banner

Retry with backoff and jitter, circuit breaker, timeout, rate limiter, and bulkhead policies for reliable async operations. Zero dependencies.

A terminal run of the breaker example: two calls fail with 503, the breaker
opens, and the calls after it fail fast without touching the network

Why this instead of what you already have #

Instead of retry. It is the package you most likely already have, and its backoff is correct: retry.dart:107 caps the exponent with math.min(attempt, 31) before math.pow, and the source says why. The difference here is scope, not correctness. That file is 188 lines and defines one class, RetryOptions, plus a top-level retry(). Nothing in it survives between calls, so there is no circuit breaker, bulkhead, or rate limiter to be had. If retrying is all you need, stay there.

Instead of polly_dart. Six of the seven policies on each side are the same, and it computes its exponent without a cap: retry_strategy.dart:236 calls math.pow(2, attemptNumber).toInt(), integer exponentiation on a 64-bit ring. Run RetryStrategyOptions.infinite() on its own defaults (1 s delay, 30 s maxDelay) and the delay first wraps negative at attempt 44 and is exactly Duration.zero from attempt 58 on. The maxDelay clamp at line 254 never fires, because zero is not greater than the cap, and line 263 returns without waiting. About 20 minutes into an outage — 1,201 seconds of accumulated delay by attempt 44 — the backoff stops backing off.

Reach for it when #

  • A dependency is down and you want to stop calling it entirely for a while, which needs state that outlives a single call.
  • One slow dependency must not consume every worker you have.
  • Many clients retry the same endpoint and you need jitter so they do not all land in the same millisecond.

Skip it if all you need is "try this three more times." Use retry: it is 188 lines, it is correct, and it is already in most lockfiles.

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

Policies wrap the action, composing as nested layers 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: 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 at max. jitter between 0 and 1 randomizes each delay within [base * (1 - jitter), base] so simultaneous clients do not retry in lockstep.

Three histograms of retry arrival times for 200 clients that failed at the same moment. With jitter off, all 200 retries land in a single 25 ms bin, three times over. At jitter 0.5 the busiest bin holds 56 retries, and at jitter 1 it holds 34.

Lockstep is the part worth seeing. Two hundred clients that failed together retry at 200 ms, 600 ms and 1400 ms on the dot once jitter is off, and the recovering dependency meets its whole caller base three times over. Half jitter drops the busiest 25 ms window to 56 callers, full jitter to 34. dart run tool/jitter_figure.dart redraws the figure from delays that Backoff.exponential returned, and refuses to write it when the peaks stop falling.

Backoff is an interface; 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. A full bucket allows a short burst; 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. 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; set delay near your p95 rather than your median.

dart run example/hedge_tail_latency.dart puts a number on both halves of that trade. A hundred requests against a service where one in twenty stalls for 600 ms:

                p50    p99    max   backend calls
no policy     22 ms 602 ms 602 ms             100
hedged        22 ms  74 ms  74 ms             105

The median does not move, because a fast call finishes long before the hedge would start and so it never starts. The tail loses 528 ms and the bill is five extra calls. Deterministic and offline, so those are the numbers you get too.

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 CircuitOpenException is 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 retryIf and 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 and 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, and Bulkhead are stateful by design: create one per protected resource and share it.
  • No global registry, no configuration files, no code generation.
0
likes
160
points
831
downloads
screenshot

Documentation

API reference

Publisher

verified publisherdeveloperyusuf.com

Weekly Downloads

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.

Repository (GitHub)
View/report issues

Topics

#retry #circuit-breaker #bulkhead #rate-limiting #fault-tolerance

License

MIT (license)

More

Packages that depend on resilience