Rate limiting
Redis-backed sliding-window rate limiting.
import { createRateLimiter, slidingWindow } from '@kreogen/rate-limit';
const limiter = createRateLimiter({
limiter: slidingWindow(10, '10 s'),
prefix: 'contact-form',
});
const { success, remaining, reset } = await limiter.limit(identifier);
if (!success) {
throw new Error('Too many requests. Try again shortly.');
}Windows accept ms, s, m, h and d — '30 s', '1h', '1d'.
How it works
Each identifier gets a Redis sorted set of request timestamps. On every call a single Lua script trims entries older than the window, counts what remains, and inserts the new one only if the count is under the limit.
Doing all three in one script matters: split into separate commands, two concurrent requests can both read a count below the limit and both be allowed.
It fails open
When REDIS_URL is unset — or Redis is unreachable — requests are allowed.
That is deliberate. A limiter exists to protect a feature, and taking the
feature down when the limiter itself is unavailable inverts the tradeoff. If a
particular route needs the opposite behaviour, check redis directly and
decide for yourself:
import { redis } from '@kreogen/rate-limit';
if (!redis) {
throw new Error('Rate limiting is required for this endpoint.');
}Configuration
| Variable | Required | Purpose |
|---|---|---|
REDIS_URL | no | redis:// or rediss:// URL |
Redis ships in docker-compose.yml, so locally this needs no setup beyond
bun run docker:up. In production point it at whatever Redis the project
uses — managed or self-hosted, both work, because this uses the standard
protocol rather than a vendor REST API.
Choosing an identifier
Prefer the authenticated user. Fall back to clientIdentifier, never to the
raw header:
import { clientIdentifier } from '@kreogen/rate-limit';
import { auth } from '@kreogen/auth/session';
import { headers } from 'next/headers';
const { userId } = await auth();
const identifier = userId ?? clientIdentifier(await headers());headers().get('x-forwarded-for') is not an identifier. XFF is a list each
proxy appends to, so the leftmost entry is whatever the original client
sent — attacker-controlled and free-form. A limiter keyed on it is not a
limiter: the caller sends a fresh random value on every request, lands in a
fresh bucket every time, and never hits the limit.
clientIdentifier counts from the right instead, since only the entries
appended by infrastructure you control are trustworthy. It defaults to one
proxy, which is the shape of every single-load-balancer deployment:
clientIdentifier(requestHeaders, { trustedProxyCount: 2 });Match that number to the deployment. Too high and the index walks back into the attacker-controlled prefix, reintroducing exactly the spoof it exists to prevent. Too low and every caller behind the outermost proxy shares one bucket.
Even correct, IP is a blunt instrument: users behind shared egress share a bucket. Use it for unauthenticated surfaces and the user id everywhere else.
Redis connection behaviour
Two settings are load-bearing for the fail-open promise, and both are the opposite of ioredis' defaults:
enableOfflineQueue: false. On, commands issued while the socket is down are buffered and replayed on reconnect — so a request that should have been answered in milliseconds hangs for as long as the outage lasts. Off, it rejects immediately andlimit()falls through to allowing the request.lazyConnect: true. Importing the package opens no socket, so a build step or a test never connects.
An error listener is attached for the same reason. ioredis emits error on
every failed connection attempt, and Node throws on an unhandled one — so a
Redis that is merely slow to start would take the whole process down, which is
the precise opposite of failing open.