Foundation.CircuitBreaker protects downstream services from cascading
failures. When a service starts failing, the circuit opens and rejects calls
immediately, giving the service time to recover.
States
A circuit breaker has three states:
| State | Behavior |
|---|---|
| Closed | Requests pass through. Failures are counted. |
| Open | Requests are rejected immediately with {:error, :circuit_open}. |
| Half-Open | A limited number of probe requests are allowed to test recovery. |
The circuit opens when failures reach failure_threshold. After
reset_timeout_ms, it transitions to half-open. A success in half-open
closes the circuit; a failure re-opens it. Ignored outcomes leave breaker
health unchanged and release any half-open probe capacity.
Functional API
The functional API is stateless -- you manage the circuit breaker struct yourself:
alias Foundation.CircuitBreaker
cb = CircuitBreaker.new("payment_service",
failure_threshold: 3,
reset_timeout_ms: 30_000,
half_open_max_calls: 1
)
# Execute through the circuit breaker
{result, cb} = CircuitBreaker.call(cb, fn ->
PaymentService.charge(amount)
end)call/3 returns {result, updated_cb} where result is either the
function's return value or {:error, :circuit_open}.
Custom Success Detection
By default, {:ok, _} is considered a success. You can customize this:
{result, cb} = CircuitBreaker.call(cb, fn ->
HTTPClient.post(url, body)
end, success?: fn
{:ok, %{status: status}} when status in 200..299 -> true
_ -> false
end)The callback may return:
trueor:successto record successfalseor:failureto record failure:ignoreto skip breaker accounting for that outcome
:ignore is useful when an HTTP client wants 429 to drive shared backoff
without counting it as a downstream health failure.
Manual State Management
# Check if the circuit allows requests
CircuitBreaker.allow_request?(cb) # true when closed or half-open with capacity
# Get the current state
CircuitBreaker.state(cb) # :closed | :open | :half_open
# Record outcomes manually
cb = CircuitBreaker.record_success(cb)
cb = CircuitBreaker.record_failure(cb)
cb = CircuitBreaker.record_ignored(cb)
# Force reset
cb = CircuitBreaker.reset(cb)Registry API
For shared circuit breakers across processes, use
Foundation.CircuitBreaker.Registry:
alias Foundation.CircuitBreaker.Registry
# Create a registry (ETS-backed)
registry = Registry.new_registry(name: MyApp.CircuitBreakers)
# Execute through a named circuit breaker
result = Registry.call(registry, "payment_service", fn ->
PaymentService.charge(amount)
end)The registry automatically creates circuit breakers on first use and shares state across all callers. Half-open probes are serialized with CAS updates to prevent multiple processes from probing simultaneously.
Breaker Creation Options
registry = Registry.new_registry(name: MyApp.CircuitBreakers)
result =
Registry.call(registry, "payment_service", fn ->
PaymentService.charge(amount)
end,
failure_threshold: 5,
reset_timeout_ms: 60_000,
half_open_max_calls: 2
)new_registry/1 only creates the ETS table. Circuit-breaker options are
applied when a named breaker is first created via call/4, and later calls
reuse the existing breaker state for that name.
Registry ownership
Since 0.2.2, default registries belong to Foundation's supervised registry owner,
not the first requesting worker. Concurrent first callers share the same table;
worker exit preserves state. Ensure the :foundation application is started
(normally automatic for a runtime dependency).
new_registry/1 creates a caller-owned table, with no heir. Its state disappears
when that owner exits. Create explicit shared registries in a long-lived process
in your supervision tree, and pass them to workers. Foundation does not transfer
these tables to Erlang :init. Application stop or registry-owner restart clears
default state; the next call recreates a default table. Previously returned raw
table references are invalid after owner restart and must be reacquired.