Resilience Patterns
Retry with backoff and jitter, dead-lettering, and a circuit breaker — each a small statechart, zero dependencies, in xstate_statemachine.patterns.
Three things every service team re-implements by hand, and gets subtly wrong: retry with backoff (no jitter → a thundering herd when the dependency comes back), dead-lettering (a poison message with no error context), and the circuit breaker (a half-open probe race). Each is a small statechart plus a delay function, so they ship as first-class building blocks in xstate_statemachine.patterns — zero dependencies, both engines, and xsm inspect renders them.
stateDiagram-v2
direction LR
state "attempting" as A
state "retrying" as R
state "done" as D
state "deadLettered" as X
A --> D : onDone / retryReset
A --> R : onError / retryBump
R --> A : after retryDelay [retryCanRetry]
R --> X : after retryDelay
🔁 Retry with backoff and jitter
RetryPolicy computes the delay after the n-th failed attempt and hands you the three pieces of logic a retry loop needs — a named delay, a guard, and two actions — under predictable names so the JSON fragment below works verbatim.
from xstate_statemachine.patterns import RetryPolicy
policy = RetryPolicy(max_attempts=5, base_ms=200, factor=2.0, max_ms=30_000, jitter="none")
print([policy.delay_ms(attempt) for attempt in range(1, 6)])
# [200.0, 400.0, 800.0, 1600.0, 3200.0]
| Argument | Default | Meaning |
|---|---|---|
max_attempts |
5 |
Total attempts including the first. With 3, the third failure is final. |
base_ms |
200 |
Delay before the first retry. |
factor |
2.0 |
Growth per attempt. |
max_ms |
30_000 |
Cap on any single delay. |
jitter |
"full" |
"none" · "full" (uniform in [0, exp]) · "equal" (exp/2 + uniform[0, exp/2]) · "decorrelated" (min(cap, uniform[base, prev×3])). Formulas follow the AWS Architecture Blog. |
rng |
random.random |
Inject a seeded Random(...).random for deterministic tests. |
"decorrelated" is non-monotone by design — each delay depends on the previous one, so its only guarantees are the bounds. The policy remembers the previous delay in context["attempt_delay_ms"].
The retry loop as a chart
The loop is four states. policy.logic() provides retryDelay, retryCanRetry, retryBump and retryReset; merge it with your own logic via MachineLogic.merge().
from xstate_statemachine import (
MachineLogic,
SimulatedClock,
SyncInterpreter,
create_machine,
)
from xstate_statemachine.patterns import RetryPolicy
RETRY_CFG = {
"id": "job",
"initial": "attempting",
"context": {
"attempt": 0,
},
"states": {
"attempting": {
"invoke": {
"src": "work",
"onDone": {
"target": "done",
"actions": "retryReset",
},
"onError": {
"target": "retrying",
"actions": "retryBump",
},
},
},
"retrying": {
"after": {
"retryDelay": [
{
"guard": "retryCanRetry",
"target": "attempting",
},
{
"target": "deadLettered",
},
],
},
},
"done": {
"type": "final",
},
"deadLettered": {
"type": "final",
"tags": ["dead-letter"],
},
},
}
calls = {
"n": 0,
}
def work(interp, ctx, event):
calls["n"] += 1
if calls["n"] <= 3:
raise ConnectionError(f"boom #{calls['n']}")
return "ok"
policy = RetryPolicy(max_attempts=5, base_ms=100, jitter="none")
machine = create_machine(
RETRY_CFG,
logic=policy.logic().merge(
MachineLogic(
services={
"work": work,
}
)
),
)
clock = SimulatedClock()
interp = SyncInterpreter(machine, clock=clock).start()
for _ in range(5):
clock.increment(10_000) # each retry delay is well under 10 s
assert interp.matches("job.done") and calls["n"] == 4
assert interp.context["attempt"] == 0 # retryReset ran on success
Three failures, then success on the fourth attempt — driven entirely by the chart and a SimulatedClock, with no sleep anywhere.
Custom names
policy.logic(prefix="upload", attempt_key="tries") yields uploadDelay / uploadCanRetry / uploadBump / uploadReset reading context["tries"], so two independent retry loops can live in one machine.
💀 Dead-lettering with error context
When the loop gives up, the chart enters a state tagged dead-letter. DeadLetterPlugin watches for that and writes one record with everything an operator needs to triage or replay: the machine id, the event being handled, the attempt count, the chain of errors collected since the last clean step, and a snapshot.
from xstate_statemachine import (
MachineLogic,
SimulatedClock,
SyncInterpreter,
create_machine,
)
from xstate_statemachine.patterns import (
DeadLetterPlugin,
DeadLetterStore,
RetryPolicy,
)
RETRY_CFG = {
"id": "job",
"initial": "attempting",
"context": {
"attempt": 0,
"api_key": "sk-live-…",
},
"states": {
"attempting": {
"invoke": {
"src": "work",
"onDone": {
"target": "done",
"actions": "retryReset",
},
"onError": {
"target": "retrying",
"actions": "retryBump",
},
},
},
"retrying": {
"after": {
"retryDelay": [
{
"guard": "retryCanRetry",
"target": "attempting",
},
{
"target": "deadLettered",
},
],
},
},
"done": {
"type": "final",
},
"deadLettered": {
"type": "final",
"tags": ["dead-letter"],
},
},
}
def work(interp, ctx, event):
raise ConnectionError(f"upstream 503 (attempt {ctx['attempt'] + 1})")
policy = RetryPolicy(max_attempts=3, base_ms=100, jitter="none")
machine = create_machine(
RETRY_CFG,
logic=policy.logic().merge(
MachineLogic(
services={
"work": work,
}
)
),
)
store = DeadLetterStore() # any callable(DeadLetter) is a sink
clock = SimulatedClock()
interp = (
SyncInterpreter(machine, clock=clock).use(DeadLetterPlugin(store)).start()
)
for _ in range(4):
clock.increment(10_000)
assert interp.matches("job.deadLettered")
record = store.all()[0]
assert record.attempts == 3
assert [e["message"] for e in record.errors] == [
"upstream 503 (attempt 1)",
"upstream 503 (attempt 2)",
"upstream 503 (attempt 3)",
]
assert (
record.snapshot["context"]["api_key"] == "***"
) # redacted before it reached the sink
print(record.to_json()[:80], "…")
What the record guarantees:
- Redacted. Dead letters contain snapshots, and snapshots contain
context. The record passes through the sameredact()asLoggingInspectorbefore it reaches the sink, so a secret never lands in a queue or a log. Override the denylist withredact_keys=. - Errors are strings. Each entry is
{"source": "service" | "action", "name", "type", "message"}— class name and message, never the exception object. - Both engines. The plugin uses engine-agnostic hooks and writes the record from
on_event_processed, after the step has settled, so the snapshot is legal on either engine. - Retention.
DeadLetterStore.purge_older_than(cutoff_wall)drops old records;taken_atisinterpreter.wall_now()(epoch seconds).
Options: state_ids=["job.poison"] to name the terminal states explicitly instead of tagging; include_snapshot=False for smaller records; attempt_key= to match a custom counter; max_errors=20 caps the chain. For broker sinks see EDA; xsm dlq lists and replays dead letters (see CLI).
Where the error chain lives — and why (battle-test #265)
In production the interpreter is discarded after every request (create → act → persist → discard): the first attempt runs in a web request, the retries in the DueTimerScanner’s wakes, each in a fresh interpreter. A chain kept in the plugin’s memory died with each of them — the record arrived with attempts=3, errors=[], and the operator could not see why. The chain therefore lives in the snapshot, under context["_xsm_errors"] (ERRORS_CONTEXT_KEY), where it survives the block boundary and travels with the record between processes.
That key is library-private: the _xsm_ prefix (PRIVATE_CONTEXT_PREFIX) is reserved. context_model(...) validates your keys only, so a model with extra="forbid" is unaffected; the Starlette / FastAPI / Litestar / Flask / Channels state bodies hand your context_serializer the public view (public_context(ctx)), so a pass-through serializer cannot leak it; redact() masks any secret value from context inside the stored messages, which are cut at 1 000 characters. It is cleared on a clean on_service_done, by the retry policy’s retryReset, and after a record is captured — so a machine that dead-letters, recovers, and fails again starts a fresh chain (while attempts resets only via retryReset: attempts=3 with a one-entry chain is possible and means “the counter was not reset between episodes”).
What was pinned by tests/patterns/test_battle_265_dead_letter.py and the fastapi_orders gateway-outage scenario:
| Situation | What happens |
|---|---|
3 failures across 3 persisted() blocks (request, scanner wake, scanner wake), then the 4th lands in the dead-letter state |
one record, attempts=3, three errors oldest-first with their types, messages redacted, no exception objects |
One plugin shared by many machines with the same id (order), 16 threads × 200 cycles |
each record carries only its own key’s errors — context is per instance, so the #261 shared-plugin collision cannot happen |
A context_model with extra="forbid" |
unaffected (private keys are filtered before model_validate) |
| The dead-letter state is the initial state | no record — start-up is not an event step |
| Two dead-letter states; re-entering one | one record per entry |
| A child actor’s dead-letter state | children do not inherit the parent’s plugins; attach it to the child (or plugins.register_global), and machine_id is the actor id |
| A sink that raises | logged at ERROR with the record id, then re-raised so on_plugin_error fires; the machine is unaffected |
A sink that is neither callable nor has put() |
TypeError at construction, not at the first dead letter |
| A sink that blocks | blocks the machine’s step — send() returns after the sink does |
| The chain inside a parallel region | per machine, not per region: region A’s errors appear in region B’s record |
DeadLetterStore.purge_older_than(NaN) / list(limit=-1) |
ValueError (NaN used to erase the whole store); put is O(1), 100 000 records bounded |
| 10 000 retry cycles | growth N/2 → N under 64 KB, no thread growth; 10 000 never-stopped interpreters leak nothing (weakly keyed fallback) |
⚡ Circuit breaker — as a statechart
The breaker is a three-state chart run on a SyncInterpreter behind one lock. That is not a gimmick: because the chart owns the state, the half-open race — two callers both believing they are the probe — is solved where it belongs, as a guarded transition taken inside the lock. Exactly half_open_max_calls probes get through per half-open window, however many threads hammer it.
As battle-tested (#265) — tests/patterns/test_battle_265_circuit_breaker.py and the gateway-outage scenario:
| Attack | Outcome |
|---|---|
32 / 128 / 512 threads released by a barrier the instant the circuit half-opens, half_open_max_calls 1 / 2 / 7, probes held in flight |
exactly N targets run; every other caller gets CircuitOpenError(state="half_open"); all probes succeeding → closed, one failing → open |
| A late SUCCESS from a probe arriving after the circuit re-opened; a call admitted while closed reporting after the circuit opened and half-opened | fixed: every admission carries a window token; an outcome from an earlier window is dropped (it used to close the circuit with no probe, or re-open it from a stale failure) |
500 acall tasks on one loop; 64 threads + 64 tasks on one breaker |
one admission rule for both |
state read from a thread that never called call() after the cooldown elapsed |
never stale (state ticks first) |
A plugin reading breaker.state inside on_transition; a target that calls the same breaker |
no deadlock (re-entrant lock); the nested call is admitted independently — the target runs outside the lock, so 8 × 0.2 s calls finish in ~0.2 s |
cooldown_ms NaN / ±inf / negative |
fixed → ValueError (NaN and inf left the circuit open for ever; negative half-opened instantly) |
reset() from open / half-open with probes in flight; 1 000 resets |
in-flight outcomes ignored; opened_count is a lifetime counter and survives (changed); no pending timers, flat threads |
call() / acall() after close() |
fixed → InterpreterStoppedError (it silently ran the target unprotected) |
| Decorator on a generator / async-generator function | fixed → TypeError at decoration (the outcome was recorded before the body ran); one decorator object on two functions no longer shares a name |
| Decorator on a method | one breaker per class, shared by every instance — document or wrap per instance |
exceptions=(ValueError,) and a KeyError; KeyboardInterrupt / SystemExit / CancelledError |
propagate, never counted |
| 100 000 closed calls | ~20 µs overhead per call; growth N/2 → N under 64 KB |
Two things the breaker does not do, by design: there is no probe timeout — a probe that never returns holds the half-open slot until it returns or reset(); and probing is demand-driven — a breaker left open with no traffic half-opens on the next state or call(), not on a timer thread (the fastapi_orders drill shows a breaker sitting half-open after every order gave up). There is no CircuitBreaker.from_snapshot: a breaker is per-process state; share the decision across workers through your store if you need a fleet-wide one.
stateDiagram-v2
direction LR
[*] --> closed
closed --> open : FAILURE [thresholdReached]
open --> half_open : after cooldown
half_open --> closed : SUCCESS
half_open --> open : FAILURE
from xstate_statemachine import SimulatedClock
from xstate_statemachine.patterns import CircuitBreaker, CircuitOpenError
clock = SimulatedClock()
breaker = CircuitBreaker(failure_threshold=2, cooldown_ms=1_000, clock=clock)
def flaky():
raise RuntimeError("upstream down")
for _ in range(2):
try:
breaker.call(flaky)
except RuntimeError:
pass
assert breaker.state == "open"
try:
breaker.call(lambda: "never runs")
except CircuitOpenError as exc:
print(exc) # Circuit 'circuitBreaker' is open; call rejected …
clock.increment(1_001) # cooldown elapses
assert breaker.state == "half_open"
assert breaker.call(lambda: "recovered") == "recovered"
assert breaker.state == "closed"
| Member | Meaning |
|---|---|
CircuitBreaker(failure_threshold=5, cooldown_ms=30_000, half_open_max_calls=1, clock=None, plugins=(), name=None, exceptions=(Exception,)) |
exceptions lists what counts as a failure; anything else propagates without touching the breaker. plugins attach to the internal interpreter — a LoggingInspector shows every trip. |
.call(fn, *a, **kw) / await .acall(fn, *a, **kw) |
Run fn if admitted, record the outcome, re-raise its exception. CircuitOpenError means the target was not invoked. |
.state |
"closed" / "open" / "half_open". Ticks the clock first, so an elapsed cooldown is reflected. |
.failures / .opened_count |
Consecutive failures in the current window; total trips. |
.reset() / .close() |
Operator override to closed; stop the internal interpreter. |
@circuit_breaker(**kw) |
One breaker per decorated function (sync or async def), exposed as fn.breaker. |
CIRCUIT_BREAKER_CONFIG / circuit_breaker_logic(cooldown_ms) |
The chart and its logic, for xsm inspect, diagrams, or embedding in your own machine. |
Dogfooding check — save CIRCUIT_BREAKER_CONFIG to a file and run xsm inspect:
╭─ inspect ──────────────────────────────── circuit_breaker.json ─╮
│ Machine circuitBreaker Version 1 │
│ States 3 atomic 3 │
│ Events 4 Timers 1 Invokes 0 │
│ Logic 5 actions · 2 guards · 0 services · 1 named delays │
╰────────────────────────────────────────────────────────────────╯
state tree
◆ circuitBreaker initial=closed
├── ○ closed
├── ○ open after cooldown #rejecting
└── ○ half_open
Async
acall awaits a coroutine result; the breaker’s own bookkeeping stays synchronous and lock-guarded, so one instance may be shared by asyncio tasks and worker threads at once. With a SimulatedClock inside a running loop, await clock.increment(ms) as usual — .state ticks the internal sync interpreter for you.
import asyncio
from xstate_statemachine import SimulatedClock
from xstate_statemachine.patterns import circuit_breaker, CircuitOpenError
clock = SimulatedClock()
@circuit_breaker(failure_threshold=1, cooldown_ms=500, clock=clock)
async def fetch(ok: bool) -> str:
if not ok:
raise TimeoutError("slow")
return "data"
async def main():
try:
await fetch(False)
except TimeoutError:
pass
assert fetch.breaker.state == "open"
try:
await fetch(True)
except CircuitOpenError:
print("rejected fast")
await clock.increment(501)
assert await fetch(True) == "data"
assert fetch.breaker.state == "closed"
asyncio.run(main())
Retry formulas, as properties (battle-test #265)
10 000 seeded draws per mode and Hypothesis over the parameter space: every delay is in [0, max_ms]; "none" is exactly min(cap, base·factor^(n−1)); "full" ∈ [0, exp]; "equal" ∈ [exp/2, exp]; "decorrelated" ∈ [base, min(cap, 3·prev)] and is not monotone (by design). Fixed: factor=1e6 / attempt=10⁶ overflowed with OverflowError (now the cap); an rng returning NaN, 1.5 or −0.1 produced a NaN / negative / over-cap delay silently (now ValueError; exactly 1.0 is accepted and capped); NaN base_ms / max_ms / factor are refused. max_ms=inf is still “no cap”. max_ms < base_ms with decorrelated jitter returns max_ms. A DueTimerScanner re-fire after a crashed save does not double-bump the attempt counter (the bump was in the step whose save failed).
Putting them together
A worker that retries with jitter, trips a breaker on a sick dependency, and dead-letters what it cannot deliver is the three pieces above composed: the breaker wraps the service call inside work, the retry chart decides when to try again, and the plugin records why it stopped. Each piece is independently testable on a SimulatedClock, and xsm inspect / xsm diagram render the whole thing.