Gryt

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

ActionWindowScoreBan
chat:send20 / 10s1 per action, max 10, decays 1 per 2s30s
chat:edit20 / 60s1, max 10, 1 per 2s
chat:delete30 / 60s1, max 15, 1 per 3s
chat:react60 / 60s0.5, max 15, 1 per 3s
chat:fetch15 / 10s0.3, max 8, 1 per 1.5s
chat:typing30 / 10s0.2, max 6, 1 per 1.5s
server:join20 / 60s0.5, max 10, 1 per 5s60s
join:requests10 / 60min1, max 10, 1 per 60s10min
voice:room:request10 / 60s1, max 8, 1 per 5s
voice:channel:joined10 / 60s0.5, max 6, 1 per 3s
webhook:send30 / 60s1, max 15, 1 per 2s60s
Moderation actions15 / 60s2, max 10, 1 per 5s
Reports10 / 60s2, max 10, 1 per 5s
Handling reports30 / 60s1, max 15, 1 per 3s
Settings changes30 / 60s1, max 20, 1 per 3s
Invites20 / 60s1, 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

{
  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:

VariableDefault
SERVER_INVITE_MAX_RETRIES8
SERVER_INVITE_RETRY_WINDOW_MS300000
SERVER_INVITE_RETRY_COOLDOWN_MS
SERVER_INVITE_MAX_COOLDOWN_MS
SERVER_INVITE_IP_MAX_RETRIES
GRYT_INVITE_MAX_JOINS_PER_HOUR

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.

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.

On this page