Docs / Developer tools / Rate limiting
Rate limiting
Rate limiting is opt-in, backed by Upstash Redis in lib/rate-limit.ts. It is not required to run Keel locally or even in a first production deploy, and it deliberately covers only Keel's own expensive routes, Better Auth already rate-limits its /api/auth/* endpoints out of the box once NODE_ENV=production.
Degrades to a no-op without config
Without UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN, getRatelimiter returns null and callers skip limiting rather than block:
const limiter = getRatelimiter("ai-chat", "1 m", 20);
if (limiter) {
const { success } = await limiter.limit(access.userId);
if (!success) {
return Response.json({ error: "Too many requests, slow down and try again shortly." }, { status: 429 });
}
}
A missing limiter must skip, never block
The if (limiter) guard is load-bearing. Because getRatelimiter returns null when Upstash isn't configured, calling .limit() unconditionally would throw on every request in a fresh clone. Always gate on the returned value, the same "skip instead of break" convention lib/email.ts and lib/ai/provider.ts follow for their optional dependencies.
Independent buckets by prefix
getRatelimiter(prefix, window, max) caches one Ratelimit per prefix:window:max combination and namespaces it under keel:<prefix> in Redis. The prefix keeps callers in separate buckets even when they limit the same identifier, so the same team hitting two different expensive routes gets an independent allowance on each.
What's limited, and why there
The one wired call site is the AI chat route (app/api/teams/[teamId]/ai/chat), limited to 20 requests per minute per user. That is the endpoint that costs real provider money per request and isn't covered by Better Auth's limiter. It sits on top of the credit system as defense in depth: credits already stop sustained abuse (a fresh team starts at a zero balance), but nothing otherwise stopped a single funded team from firing a scripted burst as fast as the client allows.
To limit another route, call getRatelimiter with a fresh prefix, pick an identifier to key on (user id, team id, or IP), and apply the same if (limiter) guard.