# `PhoenixKit.API.RateLimit`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.64.1/lib/phoenix_kit/api/rate_limit.ex#L1)

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

# `headers`

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

# `check_admin`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

# `check_user`

```elixir
@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`

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

Counts one failed authentication from `ip`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
