defmodule ReqFuse.Steps.Fuse do
@moduledoc """
Configure circuit-breaker via `:fuse`.
"""
require Logger
alias ReqFuse.Telemetry
@defaults {
{:standard, 10, 10_000},
{:reset, 30_000}
}
@fuse_keys [
:fuse_melt_func,
:fuse_mode,
:fuse_name,
:fuse_opts,
:fuse_verbose
]
@doc """
Attach the circuit-breaker :fuse step and configure the supported options.
Only fuse-options are attached here; other options are dropped.
## Fuse Options
- `:fuse_melt_func` - A 1-arity function to determine if response should melt the fuse
defaults to `ReqFuse.Steps.Fuse.melt?/1`
- `:fuse_mode` - how to query the fuse, which has two values:
- `:sync` - queries are serialized through the `:fuse_server` process (the default)
- `:async_dirty` - queries check the fuse state directly, but may not account for recent melts or resets
- `:fuse_name` - **REQUIRED** the name of the fuse to install
- `:fuse_opts` The fuse _strategy_ options (see [fuse docs](https://hexdocs.pm/fuse/fuse.html#types) for reference) (order matters)
defaults to `#{inspect(@defaults)}`.
See `defaults/0` for more information.
- `:fuse_verbose` - If false, suppress Log output
See https://github.com/jlouis/fuse#tutorial for more information about the supported fuse
strategies and their options.
### Melt function
By default ReqFuse will send a melt message to your fuse server for any request where the status is over 500.
There are many other melt options. The melt_function must be a 1-arity function that evaluates the
response. Pass the function reference in the `:fuse_melt_func` key as `&Mod.fn/arity` (or MFA notation).
Any melt function should be widely permissive of what it will evaluate.
In addition to a `%Req.Response{}` it could receive other error state messages from the underlying
HTTP adapter libraries. For example:
- `{:error, %Mint.TransportError{reason: :econnrefused}}`
- `{:error, %Mint.TransportError{reason: :timeout}}`
- `{:error, %HTTPoison{}}`
- `some_other_flavor_of_error`
## Example `melt?/1` function
```elixir
def melt?(%Req.Response{} = response) do
cond do
response.status in [408, 429] -> true
response.status >= 200 and response.status < 300 -> false
response.status < 200 -> true
end
def melt?(%Req.Response{}), do: false
def melt?(error_response), do: true
```
## Options Example
```elixir
[
fuse_melt_func: &My.Fuse.CustoMod.my_melt_function/1,
fuse_mode: :sync,
fuse_name: My.Fuse.Name,
fuse_opts: {{:standard, 1, 1000}, {:reset, 300}},
fuse_verbose: true
]
```
"""
@spec attach(Req.Request.t(), keyword()) :: Req.Request.t()
def attach(%Req.Request{} = request, options) do
_ = Keyword.fetch!(options, :fuse_name)
fuse_options = Keyword.take(options, @fuse_keys)
request
|> Req.Request.register_options(@fuse_keys)
|> Req.merge(fuse_options)
|> Req.Request.prepend_request_steps(fuse: &check_fuse_state/1)
|> Req.Request.prepend_response_steps(fuse: &melt_fuse/1)
|> Req.Request.prepend_error_steps(fuse: &melt_fuse/1)
end
@doc """
(Hopefully) reasonable fuse defaults, based on the fuse docs: `#{inspect(@defaults)}`.
- `fuse type`
- `first tuple` - Specify the fuse strategy (:standard or :fault_injection),
- :standard, permit N (3) failures in M (10_000) milliseconds
- `{:standard, N, M}`
- `{:standard, 3, 10_000}`
- :fault_injection, This fuse type sets up a fault injection scheme where the
fuse fails at rate R (0.005), N (3) and M (10_000) work similar to :standard.
e.g. `{:fault_injection, 0.005, 3, 10_000}`
- `{:fault_injection, R, N, M}`
(Inject one fault for 0.5% of requests, 3 failures in 10 seconds melts the fuse)
- `second tuple` - Specify the recover period in milliseconds
e.g. `{:reset, 30_000}` unmelt the fuse after 30 seconds.
- `{:reset, 30_000}`
"""
def defaults, do: @defaults
defp check_fuse_state(request) do
name = request.options.fuse_name
mode = Map.get(request.options, :fuse_mode, :sync)
opts = Map.get(request.options, :fuse_opts, @defaults)
verbose = Map.get(request.options, :fuse_verbose, true)
case :fuse.ask(name, mode) do
:ok ->
request
:blown ->
Telemetry.blown_fuse(name)
if verbose do
Logger.warning(":fuse circuit breaker is open; fuse = #{name}")
end
Req.Request.halt(request, RuntimeError.exception("circuit breaker is open"))
{:error, :not_found} ->
_ = :fuse.install(name, opts)
request
end
end
defp melt_fuse({request, response}) do
name = request.options.fuse_name
melt_func = Map.get(request.options, :fuse_melt_func, &melt?/1)
if melt_func.(response) do
:fuse.melt(name)
end
{request, response}
end
@doc """
A default :fuse melt test.
"""
@spec melt?(term()) :: boolean()
def melt?(%Req.Response{} = response) when response.status >= 500, do: true
def melt?(%{__exception__: true}), do: true
def melt?(_), do: false
end