Skip to main content

src/client/livery_client_balance.erl

-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.