ReqCircuitBreaker
ReqCircuitBreaker is a circuit breaker plugin for Req using Fuse.
Installation
Add req_circuit_breaker to your list of dependencies in mix.exs:
def deps do
[
{:req_circuit_breaker, "~> 0.1.0"}
]
end
Requirements
ReqCircuitBreaker runs on the currently supported Elixir versions and the compatible OTP versions. OTP 24 is not supported because Finch doesn't either.
This package is tested against the Elixir and OTP versions that are still
supported upstream. Older versions down to the requirement in mix.exs may
still work, but they are not covered by CI and not officially supported.
Circuit breakers
A circuit breaker counts failures. While the count stays under the threshold the circuit is closed and requests go through. Once the threshold is crossed the circuit opens, every call is refused without touching the network, and the circuit closes again after the reset interval. For more information, refer to https://martinfowler.com/bliki/CircuitBreaker.html.
Installing a breaker
Every circuit breaker has to be installed when your application starts:
defmodule MyApp.Application do
use Application
@impl Application
def start(_type, _args) do
:ok = ReqCircuitBreaker.install(MyApp.CircuitBreaker.Payments, failures: 5)
Supervisor.start_link(children(), strategy: :one_for_one)
end
end
A breaker is not installed on demand, because the first request would then be
unprotected, concurrent first requests would race, and a repeated install
would reset a circuit that had just opened. Using a breaker that is not
installed returns {:error, %ReqCircuitBreaker.NotInstalledError{}} from
ReqCircuitBreaker.ask/2, ReqCircuitBreaker.record_failure/1 and
ReqCircuitBreaker.reset/1, and raises it from ReqCircuitBreaker.run/3 and
from a request that has one attached.
See t:ReqCircuitBreaker.install_opt/0 for available installation options.
Usage
Attach the plugin to the request:
"https://payments.example"
|> Req.new()
|> ReqCircuitBreaker.attach(name: MyApp.CircuitBreaker.Payments)
|> Req.get(url: "/charges")
A request refused by an open circuit returns
{:error, %ReqCircuitBreaker.OpenError{}} from Req.request/2 and raises it
from Req.request!/2.
The circuit is checked before each attempt, including each retry and each redirect hop, and one failure is recorded per request rather than per attempt, so retries do not open the circuit faster than the threshold states.
See t:ReqCircuitBreaker.attach_opt/0 for available attach options.
You can use ReqCircuitBreaker for arbitrary function calls without Req as
well by using ReqCircuitBreaker.run/3.
ReqCircuitBreaker.run(MyApp.CircuitBreaker.Payments, fn ->
MyApp.Payments.charge(order)
end)
What counts as a failure
By default only 5xx responses and transport or protocol errors count as failures.
A 429 does not count as failure by default. Whether it should depends on the context of the application.
An error raised by the client, such as a Req.TooManyRedirectsError or a
decoding error, also does not count as failure.
A raise, a throw or an exit is recorded as a failure and then re-raised unchanged.
You can change the defaults by passing the :failure? and :exception_failure?
option. The :failure? function receives receives a Req.Response or an
exception as returned by Req, and the :exception_failure? function receives
the kind and the reason.
[base_url: "https://payments.example"]
|> Req.new()
|> ReqCircuitBreaker.attach(
name: MyApp.CircuitBreaker.Payments,
failure?: fn
%Req.Response{status: 429} -> true
other -> ReqCircuitBreaker.failure?(other)
end,
exception_failure?: fn
:exit, :timeout -> true
_kind, _reason -> false
end
)
The :failure? function of ReqCircuitBreaker.run/3 takes the return value of
the given function.
Tests
A circuit breaker is state outside the test process. Tests that install a
breaker under the same name cannot run concurrently.
ReqCircuitBreaker.Test.circuit_breaker/1 names one after the running test
and removes it afterwards:
import ReqCircuitBreaker.Test
setup :circuit_breaker
test "the circuit opens after a failure", %{circuit_breaker: name} do
:ok = ReqCircuitBreaker.install(name, failures: 0)
# ...
end
You can use ReqCircuitBreaker.reset/1 to close an open circuit and discards
the failures recorded so far.
Performance
:fuse keeps one process for the whole VM, and it owns a public ETS table
holding each circuit's verdict. There are two read modes: :async_dirty reads
the ETS table directly; :sync sends a message to the process and waits for its
reply. The failure counts live in the process rather than the table, so
recording a failure always sends a message, whichever mode you use.
Related Libraries
- req_ssrf - SSRF protection for Req
- safe_redirect - Redirect URI validation to avoid open redirect vulnerabilities