-module(livery_client_balance).
-moduledoc """
Client layer: spread requests across a pool of endpoints.
Picks an endpoint from the pool named in the opts, rewrites the request
to target it, and records the outcome so a failing endpoint is ejected
and a recovering one is probed back in. Selection load and health live in
`livery_client_balance_store`. Add it with `livery_client:balance/1`.
`Opts`: `name` (required), `endpoints` (a list of base URLs, or a
`{Module, Arg}` discovery pair), `policy` (`p2c` default | `round_robin`),
`eject_after` (consecutive failures to eject, default 5), `eject_for`
(ms ejected before a half-open probe, default 10000), `fail_status`
(what counts as a failure: a `fun((status()) -> boolean())` or a list of
statuses; default treats any status `>= 500` and any `{error, _}` as a
failure).
""".
-export([call/3]).
-define(DEFAULT_EJECT_AFTER, 5).
-define(DEFAULT_EJECT_FOR, 10000).
-spec call(livery_client:request(), livery_client:next(), map()) ->
{ok, livery_client:response()} | {error, term()}.
call(Req, Next, Opts) ->
Name = maps:get(name, Opts),
Endpoints = livery_client_discover:resolve(maps:get(endpoints, Opts, [])),
ok = livery_client_balance_store:ensure(Name, Endpoints),
Policy = maps:get(policy, Opts, p2c),
EjectFor = maps:get(eject_for, Opts, ?DEFAULT_EJECT_FOR),
case livery_client_balance_store:pick(Name, Policy, EjectFor) of
{error, no_endpoint} = Error ->
Error;
{ok, Endpoint, Token} ->
run(Req, Next, Opts, Name, Endpoint, Token, EjectFor)
end.
run(Req, Next, Opts, Name, Endpoint, Token, EjectFor) ->
EjectAfter = maps:get(eject_after, Opts, ?DEFAULT_EJECT_AFTER),
Fail = fail_status(maps:get(fail_status, Opts, default)),
Url = livery_client:rebase(Endpoint, maps:get(url, Req)),
try Next(Req#{url => Url}) of
Result ->
Outcome = classify(Result, Fail),
livery_client_balance_store:record(Name, Endpoint, Outcome, EjectAfter, EjectFor),
Result
catch
Class:Reason:Stack ->
livery_client_balance_store:record(Name, Endpoint, err, EjectAfter, EjectFor),
erlang:raise(Class, Reason, Stack)
after
livery_client_balance_store:release(Token)
end.
classify({error, _Reason}, _Fail) ->
err;
classify({ok, #{status := Status}}, Fail) ->
case Fail(Status) of
true -> err;
false -> ok
end.
fail_status(default) -> fun(Status) -> Status >= 500 end;
fail_status(Fun) when is_function(Fun, 1) -> Fun;
fail_status(List) when is_list(List) -> fun(Status) -> lists:member(Status, List) end.