Rate limits of the shop 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 |
| public, per cart token | 1 min | cost units | api_public_cart_rate_per_min |
| public, per client network | 1 min | cost units | api_public_ip_rate_per_min |
| cart rows, per client network | 1 hour | new carts | api_public_cart_rows_per_ip_hour |
| cart rows, all clients | 1 hour | new carts | api_public_cart_rows_global_hour |
The client network is PhoenixKit.Utils.IpAddress.network/1: an IPv4 address
as it is, an IPv6 address as its /64.
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 cart against the per-network and the global hourly limits.
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 cart token's bucket (when there is one) is charged,
then the network's.
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_cart_rows(String.t() | nil) :: :ok | {:error, pos_integer()}
Counts the first write of a new cart against the per-network and the global hourly limits.
@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 cart token's bucket (when there is one) is charged,
then the network's.
A client whose network bucket is full is refused before any cart counter is created, so fresh random cart tokens cannot grow the shared counter table. A request refused by the cart bucket is not charged to the network bucket, so a client hammering with one token does not use up the shared address. Headers describe the bucket with the least room left.
@spec record_auth_failure(String.t() | nil) :: :ok
Counts one failed authentication from ip.