PhoenixKit.API.RateLimit (phoenix_kit v2.64.1)

Copy Markdown View Source

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.

BucketWindowCountsSetting
admin pre-auth, per client network1 minfailed authentications onlyapi_admin_preauth_fail_per_min
admin, per key1 mincost unitsapi_admin_rate_per_min
admin, per user acting through a live session1 mincost unitsapi_admin_rate_per_min
public, per client token1 mincost unitsapi_public_token_rate_per_min
public, per client network1 mincost unitsapi_public_ip_rate_per_min
new rows, per client network1 hournew rowsapi_public_new_rows_per_ip_hour
new rows, all clients1 hournew rowsapi_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
end

The 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

headers()

@type headers() :: [{String.t(), String.t()}]

Functions

check_admin(key_uuid, cost)

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

check_new_rows(ip)

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

check_preauth(ip)

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

check_public(ip, client_token_hash, cost)

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

check_public(ip, client_token_hash, cost, network_cost)

@spec check_public(
  String.t() | nil,
  String.t() | nil,
  pos_integer(),
  non_neg_integer()
) ::
  {:ok, headers()} | {:error, pos_integer(), headers()}

check_user(user_uuid, cost)

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

record_auth_failure(ip)

@spec record_auth_failure(String.t() | nil) :: :ok

Counts one failed authentication from ip.