-module(livery_client_retry).
-moduledoc """
Client layer: retry failed requests with exponential backoff.
Retries on transport errors and on the configured retryable status codes
(502/503/504 by default), up to `max` extra attempts, sleeping
`Base * Factor^Attempt` ms plus jitter between tries. Only idempotent
methods (GET/HEAD/PUT/DELETE/OPTIONS) are retried unless
`retry_non_idempotent => true`, and a request with a one-shot streaming
body is never retried (the producer cannot be replayed). Add it with
`livery_client:retry/1`.
When a retryable response carries a `Retry-After` header (delta-seconds),
that delay is honored instead of the computed backoff, capped by
`retry_after_max` ms. An HTTP-date `Retry-After` falls back to backoff.
`Opts`: `max` (default 2), `backoff` (`{BaseMs, Factor}`, default
`{200, 2.0}`), `statuses` (default `[502, 503, 504]`),
`retry_non_idempotent` (default `false`), `retry_after_max` (default
120000).
""".
-export([call/3]).
-spec call(livery_client:request(), livery_client:next(), map()) ->
{ok, livery_client:response()} | {error, term()}.
call(Req, Next, Opts) ->
Max = maps:get(max, Opts, 2),
loop(Req, Next, Opts, Max, 0).
loop(Req, Next, Opts, Max, Attempt) ->
Result = Next(Req),
case retry(Result, Req, Opts, Attempt, Max) of
true ->
timer:sleep(delay(Result, Opts, Attempt)),
loop(Req, Next, Opts, Max, Attempt + 1);
false ->
Result
end.
retry(Result, Req, Opts, Attempt, Max) ->
Attempt < Max andalso
replayable(Req, Opts) andalso
retryable(Result, Opts).
%% A streaming request body cannot be replayed, so do not retry it.
replayable(Req, Opts) ->
case maps:get(body, Req, empty) of
{stream, _} -> false;
_ -> idempotent(maps:get(method, Req), Opts)
end.
idempotent(_Method, #{retry_non_idempotent := true}) ->
true;
idempotent(Method, _Opts) ->
lists:member(
normalize(Method),
[<<"get">>, <<"head">>, <<"put">>, <<"delete">>, <<"options">>]
).
normalize(M) when is_atom(M) -> normalize(atom_to_binary(M, utf8));
normalize(M) when is_binary(M) -> string:lowercase(M).
retryable({error, _Reason}, _Opts) ->
true;
retryable({ok, #{status := Status}}, Opts) ->
lists:member(Status, maps:get(statuses, Opts, [502, 503, 504])).
%% Honor a Retry-After header (delta-seconds) when present, capped by
%% `retry_after_max`; otherwise fall back to exponential backoff.
delay(Result, Opts, Attempt) ->
case retry_after_ms(Result) of
undefined -> backoff(Opts, Attempt);
Ms -> min(Ms, maps:get(retry_after_max, Opts, 120000))
end.
retry_after_ms({ok, #{headers := Headers}}) ->
case find_header(<<"retry-after">>, Headers) of
undefined -> undefined;
Value -> parse_delay_seconds(Value)
end;
retry_after_ms(_Result) ->
undefined.
%% Only the delta-seconds form is supported; an HTTP-date returns undefined
%% (the caller falls back to backoff).
parse_delay_seconds(Value) ->
case string:to_integer(string:trim(Value)) of
{Secs, Rest} when is_integer(Secs), Secs >= 0 ->
case string:trim(Rest) of
<<>> -> Secs * 1000;
"" -> Secs * 1000;
_ -> undefined
end;
_ ->
undefined
end.
find_header(Name, Headers) ->
L = string:lowercase(Name),
case lists:search(fun({K, _}) -> string:lowercase(K) =:= L end, Headers) of
{value, {_, V}} -> V;
false -> undefined
end.
backoff(Opts, Attempt) ->
{Base, Factor} = maps:get(backoff, Opts, {200, 2.0}),
Delay = round(Base * math:pow(Factor, Attempt)),
Delay + rand:uniform(Base).