Circuit breaker¶
The inventory API is down. Every task still waits, times out, retries, and piles onto the same unhappy dependency.
A circuit breaker is the bouncer at the door: it counts failures, opens after too many in a row, and sends new work away until a cool-off passes. Callers stop paying for timeouts while the dependency is clearly struggling.
circuit_breaker adds that pattern to your task using Stores — one breaker config per task class by default, or scoped per tenant/service when you pass a key resolver.
Basic usage¶
One breaker per task class (the default):
class FetchInventory < CMDx::Task
circuit_breaker threshold: 3, cool_off: 10
required :sku, coerce: :string
def work
context.inventory = InventoryClient.fetch(sku, timeout: 2)
end
end
FetchInventory.execute(sku: "ABC-123") # raises → failure recorded
# after enough failures…
FetchInventory.execute(sku: "ABC-123") # failed: circuit open (work not run)
| Option | Default | Plain-English meaning |
|---|---|---|
threshold: |
5 |
How many failures inside window: before the circuit opens |
cool_off: |
30 |
How long (seconds) the circuit stays open |
window: |
60 |
Sliding window (seconds) for the failure counter |
namespace: |
"cmdx:cb" |
First piece of store keys |
on: |
StandardError |
Exception classes that count as “this dependency failed” |
on_open: |
:fail |
:fail → failed result; :skip → skipped result (metadata[:code] == :circuit_open) |
if: / unless: |
nil |
Turn the breaker on or off for this run |
Scoping the breaker¶
Pass a key resolver when one task talks to several downstreams (or tenants). Same shapes as Idempotency key resolvers:
Explicit “one breaker per task type”:
Count only real dependency pain — not every StandardError in your app:
When the resolver is not :class and the segment is blank (missing context value), the breaker steps aside for that run — same as Idempotency with a blank key.
Store keys¶
Failure counts (circuit closed) use CMDx::StoreKey.compose(base, "failures"). The open flag uses …:open with value "open" and TTL cool_off:.
Examples:
cmdx:cb:FetchInventory:failurescmdx:cb:FetchInventory:opencmdx:cb:ChargeCustomer:acct_123:failures(withcircuit_breaker :stripe_account)
When the circuit is open, results include metadata[:circuit_breaker_key] (the full …:open key) and, when the backend supports TTL reads, metadata[:retry_after] (seconds until the open flag expires).
Failure window¶
window: is sliding, not “reset at midnight.” Each counted failure calls Stores.incr with ttl: window, and built-in adapters refresh expiry on every bump. Go window seconds without a new failure and the counter key disappears — the next failure starts from 1 again.
Interaction with retry_on¶
Breakers run early; idempotency (if any) sits just inside:
resolve_inputs! → rate_limit → circuit check → lock → idempotent claim → retry loop { work } → verify_outputs!
Failures are recorded when a matching on: exception escapes the retry loop (after attempts are exhausted), not on every retry swing. A successful run clears the failure counter. Logical failures that only use fail! or validation errors do not trip the breaker.
Use a shared store in production (Stores::Redis on CMDx.configuration.store). The in-memory default only sees your current process. See Stores.
Store availability¶
When the store itself is flaky, CMDx picks a side — on purpose:
| Situation | Behavior |
|---|---|
| Circuit open | work does not run; on_open: applies. |
| Read fails while checking open | Treated as closed (fail-open): work runs. |
| Write fails after a failed run | The work exception still raises; the failure might not be counted. |
| Write fails after a successful run | The failure counter might not clear; the next error may open the circuit sooner. |
Concurrency¶
The breaker peeks at the open flag again right before work. There is no distributed lock — a task can still start moments before another process opens the circuit, and when the :open TTL expires many workers may call the dependency at once. Plan for that at the dependency layer if you need stricter guarantees.
Inheritance¶
Subclasses inherit the parent’s breaker. circuit_breaker with no arguments returns the current config (including inherited). Pass a new key to replace it; pass only options to merge (e.g. circuit_breaker threshold: 3 keeps :class when the parent enabled the breaker that way). circuit_breaker false on a subclass turns it off.
class ExternalApiTask < CMDx::Task
circuit_breaker :class, threshold: 5, cool_off: 30
end
class FetchInventory < ExternalApiTask
circuit_breaker threshold: 3 # key stays :class, threshold → 3
end
class PreviewInventory < ExternalApiTask
circuit_breaker false # no breaker; work runs every time
end
Telemetry¶
When the breaker is active for a run (enabled, gates pass, key resolves), CMDx emits telemetry only if someone subscribed.
| Event | When | Extra event.payload keys |
|---|---|---|
:circuit_open |
Open flag present — work does not run |
code: :circuit_open, circuit_breaker_key, on_open:; optional retry_after |
:circuit_tripped |
Failure count reached threshold: and the open flag was set |
circuit_breaker_key, failures_key, failure_count, threshold, cool_off |
:circuit_reset |
Successful run removed the failure counter | failures_key |
:circuit_tripped fires on the run that opens the circuit (after the counting failure escapes the retry loop). The next run gets :circuit_open when it is turned away at the door.
:circuit_reset only fires when a failure key was actually deleted — not on every success.
CMDx.configure do |config|
config.telemetry.subscribe(:circuit_tripped, ->(event) {
StatsD.increment("cmdx.circuit.tripped", tags: ["task:#{event.task}"])
})
config.telemetry.subscribe(:circuit_open, ->(event) {
StatsD.increment("cmdx.circuit.open", tags: [
"task:#{event.task}",
"on_open:#{event.payload[:on_open]}"
])
})
end
:circuit_open fires before skip! / fail!, same timing as metadata[:code] == :circuit_open on the result.