-module(livery_ratelimit).
-moduledoc """
Per-key rate-limiting / throttling middleware (token bucket).
Each client (by default identified by its Authorization bearer token) gets
a token bucket: `Capacity` tokens that refill at `RefillPerSec`. A request
consumes one token; when the bucket is empty the request is shed with
`429 Too Many Requests`. A request with no key passes through unlimited.
Build the stack entry with `limiter/2,3` (which allocates an isolated
keyspace):
```erlang
Stack = [
{livery_ratelimit, livery_ratelimit:limiter(100, 10)} %% burst 100, 10/s
].
```
"N requests per minute" maps to `limiter(N, N/60)`. Per-key state lives in
the supervised `livery_ratelimit_store` ETS table; the raw key is never
stored (it is SHA-256 hashed). Responses carry
`RateLimit-Limit`/`-Remaining`/`-Reset` (and `Retry-After` on a 429)
unless `headers => false`.
Security: the default key is the bearer token, which the client controls.
That gives per-credential quotas, NOT protection against an unauthenticated
flood: a client rotating tokens gets a fresh bucket each time. Client IP is
not yet surfaced by the wire libs, so for flood protection key on a trusted
identity via a custom `key` fun (an authenticated user id, or a forwarded-IP
header you trust because you terminate behind a known proxy). The store caps
its key count (see `livery_ratelimit_store`) so distinct-key floods bound
memory regardless.
""".
-behaviour(livery_middleware).
-export([limiter/2, limiter/3, call/3]).
-export_type([state/0]).
-type key_fun() :: fun((livery_req:req()) -> binary() | undefined).
-type state() :: #{
name := term(),
capacity := non_neg_integer(),
rate := number(),
key := key_fun(),
status := 100..599,
body := iodata(),
headers := boolean()
}.
-doc "Build a limiter: `Capacity` burst tokens refilling at `RefillPerSec`.".
-spec limiter(non_neg_integer(), number()) -> state().
limiter(Capacity, RefillPerSec) ->
limiter(Capacity, RefillPerSec, #{}).
-doc """
`limiter/2` with options: `name`, `key` (a `req -> binary | undefined`
fun, default the bearer token), `status` (default 429), `body`, and
`headers` (default `true`).
""".
-spec limiter(non_neg_integer(), number(), map()) -> state().
limiter(Capacity, RefillPerSec, Opts) when
is_integer(Capacity), Capacity >= 0, is_number(RefillPerSec), RefillPerSec >= 0
->
#{
name => maps:get(name, Opts, make_ref()),
capacity => Capacity,
rate => RefillPerSec,
key => maps:get(key, Opts, fun default_key/1),
status => maps:get(status, Opts, 429),
body => maps:get(body, Opts, <<"too many requests">>),
headers => maps:get(headers, Opts, true)
}.
-doc "Throttle the request by its key, or pass through when keyless.".
-spec call(livery_req:req(), livery_middleware:next(), state()) ->
livery_resp:resp().
call(Req, Next, #{key := KeyFun} = State) ->
case KeyFun(Req) of
undefined -> Next(Req);
Key when is_binary(Key) -> limit(Key, Req, Next, State)
end.
-spec default_key(livery_req:req()) -> binary() | undefined.
default_key(Req) ->
livery_ext:bearer_token(Req).
-spec limit(binary(), livery_req:req(), livery_middleware:next(), state()) ->
livery_resp:resp().
limit(Key, Req, Next, #{name := Name, capacity := Cap, rate := Rate} = State) ->
Digest = crypto:hash(sha256, Key),
Now = erlang:monotonic_time(microsecond),
case livery_ratelimit_store:check(Name, Digest, Cap, Rate, Now) of
{allow, Remaining, Reset} ->
allow_headers(Next(Req), State, Remaining, Reset);
{deny, RetryAfter} ->
denied(State, RetryAfter)
end.
-spec allow_headers(
livery_resp:resp(), state(), float(), non_neg_integer() | undefined
) -> livery_resp:resp().
allow_headers(Resp, #{headers := false}, _Remaining, _Reset) ->
Resp;
allow_headers(Resp, #{headers := true, capacity := Cap}, Remaining, Reset) ->
rl_headers(Resp, Cap, trunc(Remaining), Reset).
-spec denied(state(), non_neg_integer() | undefined) -> livery_resp:resp().
denied(#{headers := false, status := Status, body := Body}, _RetryAfter) ->
livery_resp:text(Status, Body);
denied(#{headers := true, status := Status, body := Body, capacity := Cap}, RetryAfter) ->
Resp = rl_headers(livery_resp:text(Status, Body), Cap, 0, undefined),
maybe_retry_after(Resp, RetryAfter).
-spec rl_headers(
livery_resp:resp(), non_neg_integer(), non_neg_integer(), non_neg_integer() | undefined
) -> livery_resp:resp().
rl_headers(Resp, Cap, Remaining, Reset) ->
R1 = livery_resp:with_header(<<"ratelimit-limit">>, integer_to_binary(Cap), Resp),
R2 = livery_resp:with_header(
<<"ratelimit-remaining">>, integer_to_binary(Remaining), R1
),
maybe_reset(R2, Reset).
-spec maybe_reset(livery_resp:resp(), non_neg_integer() | undefined) ->
livery_resp:resp().
maybe_reset(Resp, undefined) ->
Resp;
maybe_reset(Resp, Reset) ->
livery_resp:with_header(<<"ratelimit-reset">>, integer_to_binary(Reset), Resp).
-spec maybe_retry_after(livery_resp:resp(), non_neg_integer() | undefined) ->
livery_resp:resp().
maybe_retry_after(Resp, undefined) ->
Resp;
maybe_retry_after(Resp, Secs) ->
livery_resp:with_header(<<"retry-after">>, integer_to_binary(Secs), Resp).