# Rate Limiting

Source: https://docs.gryt.chat/docs/server/rate-limiting

How the server decides somebody is going too fast

Two limiters run on every rate-limited action, and both have to pass. One is a
plain sliding window: how many times in the last N milliseconds. The other is a
score that rises with each action and decays with time.

The window catches a burst. The score catches somebody who has worked out where
the window sits and is pacing themselves just under it, because the score does
not reset the way a window does. Cheap actions can be worth a fraction of a
point, which is how typing indicators are allowed to be frequent without being
free.

## The key

Everything is counted per `event:userId:ip`. A member on two networks gets two
buckets, and two members behind one address get two as well, which matters more
than it sounds: without a correct
[`GRYT_TRUSTED_PROXY_HOPS`](https://docs.gryt.chat/docs/guide/configuration#behind-a-proxy), every
client behind a proxy arrives with the proxy's address and shares one bucket.

## The score

Each action adds `scorePerAction`. Before that, the score is reduced by one point
for every `scoreDecayMs` that have passed since it was last touched, floored at
zero. When the result is over `maxScore` the action is refused and the caller is
told to wait `currentScore * scoreDecayMs`.

Waiting longer than you need to costs nothing, since the score keeps decaying.

## The window

Timestamps outside `windowMs` are dropped, and the action is refused if `limit`
of them remain. The wait is however long until the oldest one falls out.

## Bans

Some rules carry `banMs`. When one of those trips, the key is banned outright for
that long and every check returns immediately without touching either counter.
The ban is applied once, on the first refusal, rather than being extended by
each retry.

## What each action costs

| Action | Window | Score | Ban |
|--------|--------|-------|-----|
| `chat:send` | 20 / 10s | 1 per action, max 10, decays 1 per 2s | 30s |
| `chat:edit` | 20 / 60s | 1, max 10, 1 per 2s | — |
| `chat:delete` | 30 / 60s | 1, max 15, 1 per 3s | — |
| `chat:react` | 60 / 60s | 0.5, max 15, 1 per 3s | — |
| `chat:fetch` | 15 / 10s | 0.3, max 8, 1 per 1.5s | — |
| `chat:typing` | 30 / 10s | 0.2, max 6, 1 per 1.5s | — |
| `server:join` | 20 / 60s | 0.5, max 10, 1 per 5s | 60s |
| `join:requests` | 10 / 60min | 1, max 10, 1 per 60s | 10min |
| `voice:room:request` | 10 / 60s | 1, max 8, 1 per 5s | — |
| `voice:channel:joined` | 10 / 60s | 0.5, max 6, 1 per 3s | — |
| `webhook:send` | 30 / 60s | 1, max 15, 1 per 2s | 60s |
| Moderation actions | 15 / 60s | 2, max 10, 1 per 5s | — |
| Reports | 10 / 60s | 2, max 10, 1 per 5s | — |
| Handling reports | 30 / 60s | 1, max 15, 1 per 3s | — |
| Settings changes | 30 / 60s | 1, max 20, 1 per 3s | — |
| Invites | 20 / 60s | 1, max 10, 1 per 5s | — |

Anything without its own rule falls back to 100 per 60 seconds with no score.

Two of these are worth reading twice. Moderation and reports cost two points
each, so an account cannot sit and mass-report. And `join:requests` is measured
over an hour rather than a minute, with a ten minute ban, because a join request
is a thing a human does once.

## What the client gets back

```ts
{
  error: "rate_limited",
  retryAfterMs: number,
  currentScore?: number,
  maxScore?: number
}
```

`currentScore` and `maxScore` are only present when it was the score that
refused, not the window. The message the client shows is built from
`retryAfterMs` rounded up to whole seconds.

## Invites are separate

Invite codes have their own brute-force protection, configured by environment
variable rather than in code, with cooldowns that escalate:

| Variable | Default |
|----------|---------|
| `SERVER_INVITE_MAX_RETRIES` | `8` |
| `SERVER_INVITE_RETRY_WINDOW_MS` | `300000` |
| `SERVER_INVITE_RETRY_COOLDOWN_MS` | — |
| `SERVER_INVITE_MAX_COOLDOWN_MS` | — |
| `SERVER_INVITE_IP_MAX_RETRIES` | — |
| `GRYT_INVITE_MAX_JOINS_PER_HOUR` | — |

<Callout type="warn" title="Counters live in memory">
The limiter is a process-local map. Restarting the server clears every score,
window and ban, and running more than one server process means each keeps its
own counts. Stale entries are evicted once a minute: bans when they expire,
windows when their newest timestamp falls out, and scores when they reach zero
or go five minutes untouched.
</Callout>

## Debugging

Bans are logged as they are applied, with the key, the score and the ban length:

```
🚫 Rate limit ban applied (score-based) { key: 'chat:send:user123:203.0.113.10', score: 11, maxScore: 10, banMs: 30000 }
```

A key that reads `…:anonymous:unknown` means neither a user ID nor an address
reached the limiter, and everybody in that state shares one bucket.
