Rate limits of the machine API.
Counters live in the one Hammer ETS table core already supervises
(PhoenixKit.Users.RateLimiter.Backend), under keys that start with api:.
Windows are fixed, aligned to the wall clock, and counted per node. Limits are
read from Settings on every call (PhoenixKit.API.Config.rate_limits/0), so an
edit takes effect at once.
| Bucket | Window | Counts | Setting |
|---|---|---|---|
| admin pre-auth, per client network | 1 min | failed authentications only | api_admin_preauth_fail_per_min |
| admin, per key | 1 min | cost units | api_admin_rate_per_min |
| admin, per user acting through a live session | 1 min | cost units | api_admin_rate_per_min |
| public, per client token | 1 min | cost units | api_public_token_rate_per_min |
| public, per client network | 1 min | cost units | api_public_ip_rate_per_min |
| new rows, per client network | 1 hour | new rows | api_public_new_rows_per_ip_hour |
| new rows, all clients | 1 hour | new rows | api_public_new_rows_global_hour |
The client network is PhoenixKit.Utils.IpAddress.network/1: an IPv4 address
as it is, an IPv6 address as its /64.
The client token is a hash of the input field a public tool declares as its
client token (PhoenixKit.API.Tool): the caller's identity where it has no
key. The new-rows quota (check_new_rows/1) is charged by the module that
creates the rows, and is one quota shared by every module.
Throttling key guessing without locking out a valid key
The pre-auth bucket is peeked (check_preauth/1 charges nothing) only
after a key has been rejected, and charged only by a failure
(record_auth_failure/1). A request is authenticated whatever the state of
the bucket: a valid key from an exhausted address goes through, because the
per-key bucket is independent of the address; a request that fails is
answered 429 instead of 401, and that failure is not charged again. A
neighbour sharing an egress address therefore cannot lock the accounting
system out by guessing.
The order the Auth plug (PhoenixKit.API.Auth) follows, after the API switch
check (404 when off) and the refusal of a credential in the URL (400):
case Keys.verify(token, ip) do
{:ok, actor} ->
check_admin(actor.key_uuid, cost)
{:error, _reason} ->
case check_preauth(ip) do
# 429: the address is exhausted; the failure is not charged again.
{:error, retry_after_s} -> :too_many_requests
# Charge the failure and answer 401 (403 for a key used from a
# forbidden address).
:ok -> :ok = record_auth_failure(ip)
end
endThe bucket limits the answers a guessing address gets. It does not save the
database lookup of Keys.verify/2: a guessed token with an unknown prefix is
not cached negatively (accepted, see PhoenixKit.API.Keys.Cache.fetch/2).
Headers
A successful check returns ratelimit-limit, ratelimit-remaining and
ratelimit-reset (seconds until the window ends). A refusal also carries
retry-after. Names are lower case, as Plug.Conn.put_resp_header/3 requires.
Summary
Functions
Charges cost units to the admin bucket of a key.
Counts the first write of a new row against the per-network and the global hourly limits. A module calls it when it creates a row for a caller without a key; the quota is shared by every module.
Whether ip may still fail authentication. Peeks; charges nothing.
Charges cost units to the public buckets: the client network's room is
checked first, then the client token's bucket (when there is one) is charged
cost, then the network's is charged network_cost (cost unless given;
0 charges it nothing, and only reports its state).
Charges cost units to the admin bucket of a user acting through a live session
(PhoenixKit.API.Executor.execute_as/5): one bucket per user, shared by all of that
user's sessions and apart from the buckets of the user's keys. The limit is the per-key
one, api_admin_rate_per_min.
Counts one failed authentication from ip.
Types
Functions
@spec check_admin(String.t(), pos_integer()) :: {:ok, headers()} | {:error, pos_integer(), headers()}
Charges cost units to the admin bucket of a key.
Returns the headers to send, or {:error, retry_after_s, headers} when the key
is over its limit.
@spec check_new_rows(String.t() | nil) :: :ok | {:error, pos_integer()}
Counts the first write of a new row against the per-network and the global hourly limits. A module calls it when it creates a row for a caller without a key; the quota is shared by every module.
@spec check_preauth(String.t() | nil) :: :ok | {:error, pos_integer()}
Whether ip may still fail authentication. Peeks; charges nothing.
Returns {:error, retry_after_s} once the address has failed
api_admin_preauth_fail_per_min times in the current minute.
@spec check_public(String.t() | nil, String.t() | nil, pos_integer()) :: {:ok, headers()} | {:error, pos_integer(), headers()}
Charges cost units to the public buckets: the client network's room is
checked first, then the client token's bucket (when there is one) is charged
cost, then the network's is charged network_cost (cost unless given;
0 charges it nothing, and only reports its state).
The executor charges the network bucket 1 unit before it looks a tool up
(check_public(ip, nil, 1): the token is not known yet), then the token
bucket the tool's full price and the network bucket the rest
(check_public(ip, hash, cost, cost - 1)), so one call costs cost units in
each bucket.
A client whose network bucket is full is refused before any token counter is created, so fresh random tokens cannot grow the shared counter table. A request refused by the token bucket is not charged to the network bucket (but it paid its first unit there, before the lookup), so a client hammering with one token does not use up the shared address. Headers describe the bucket with the least room left.
@spec check_public( String.t() | nil, String.t() | nil, pos_integer(), non_neg_integer() ) :: {:ok, headers()} | {:error, pos_integer(), headers()}
@spec check_user(String.t(), pos_integer()) :: {:ok, headers()} | {:error, pos_integer(), headers()}
Charges cost units to the admin bucket of a user acting through a live session
(PhoenixKit.API.Executor.execute_as/5): one bucket per user, shared by all of that
user's sessions and apart from the buckets of the user's keys. The limit is the per-key
one, api_admin_rate_per_min.
@spec record_auth_failure(String.t() | nil) :: :ok
Counts one failed authentication from ip.