Elixir Circuit Breakers
A circuit breaker is a guard that stops calls to a failing service. It opens after too many failures and later allows one trial call in its half-open state.
Failures below the limit keep the guard closed
A guard with a limit of 3 stays closed after 2 failures. The failure count is the proof.
defmodule Breaker do
def new(limit), do: %{state: :closed, failures: 0, limit: limit}
def fail(state) do
failures = state.failures + 1
if failures >= state.limit do
%{state | state: :open, failures: failures}
else
%{state | failures: failures}
end
end
end
breaker = Breaker.new(3)
breaker = Breaker.fail(breaker)
breaker = Breaker.fail(breaker)
IO.inspect(breaker.state)
IO.puts(breaker.failures)Reaching the limit opens the guard
One more failure reaches the limit of 3. The guard then blocks calls instead of passing them on.
defmodule Breaker do
def new(limit), do: %{state: :closed, failures: 0, limit: limit}
def fail(state) do
failures = state.failures + 1
if failures >= state.limit do
%{state | state: :open, failures: failures}
else
%{state | failures: failures}
end
end
def allowed?(state), do: state.state != :open
end
breaker = Breaker.new(3)
breaker = Breaker.fail(breaker)
breaker = Breaker.fail(breaker)
breaker = Breaker.fail(breaker)
IO.inspect(breaker.state)
IO.inspect(Breaker.allowed?(breaker))A trial in the half-open state decides the next state
An open guard moves to its half-open state for one trial. A successful trial closes the guard and clears the count.
defmodule Breaker do
def new(limit), do: %{state: :closed, failures: 0, limit: limit}
def fail(state) do
failures = state.failures + 1
if failures >= state.limit do
%{state | state: :open, failures: failures}
else
%{state | failures: failures}
end
end
def trial(state) do
if state.state == :open do
%{state | state: :half_open}
else
state
end
end
def succeed(state) do
if state.state == :half_open do
%{state | state: :closed, failures: 0}
else
%{state | failures: 0}
end
end
end
breaker = Breaker.new(2)
breaker = Breaker.fail(breaker)
breaker = Breaker.fail(breaker)
after_trial = Breaker.trial(breaker)
after_success = Breaker.succeed(after_trial)
IO.inspect(after_trial.state)
IO.inspect(after_success.state)
IO.puts(after_success.failures)Example
defmodule Breaker do
def new(limit), do: %{state: :closed, failures: 0, limit: limit}
def fail(state) do
failures = state.failures + 1
if failures >= state.limit do
%{state | state: :open, failures: failures}
else
%{state | failures: failures}
end
end
def trial(state) do
if state.state == :open do
%{state | state: :half_open}
else
state
end
end
end
start = Breaker.new(2)
open_guard = Breaker.fail(Breaker.fail(start))
trial_guard = Breaker.trial(open_guard)
open_again = Breaker.fail(trial_guard)
states = [start.state, open_guard.state, trial_guard.state, open_again.state]
IO.inspect(states)Key points
- Closed: the guard passes calls through while failures stay below the limit.
- Open: the guard blocks calls at once after the failure limit is reached.
- Half-open: the guard allows one trial call to test whether the service recovered.
- Trial success: a successful trial call closes the guard and clears the failure count.
- Trial failure: a failed trial call opens the guard again.
In this example, a guard with a limit of 2 starts closed, opens after two failures, moves to its half-open state for a trial, and opens again when the trial fails. The printed list shows each state in order.
Note
the guard counts results, not time, so this lesson shows state changes without any waiting.
Try it Yourself
Edit the code, then press Run.
Running is simulated
Exercise
A guard with a limit of 2 has failed twice and is open. What happens on its trial call if that call succeeds?