Rate limiting
Sliding-window, fixed-window and token-bucket rate limiting against a CapyDB K/V store, using @upstash/ratelimit at its default settings.
Rate limiting is the reason most people reach for a key-value store, so it is worth stating plainly:
CapyDB K/V runs @upstash/ratelimit unmodified, at its
default settings. There is no CapyDB adapter and no configuration flag.
The whole integration
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: new Redis({
url: process.env.CAPYDB_KV_REST_URL!,
token: process.env.CAPYDB_KV_REST_TOKEN!,
}),
limiter: Ratelimit.slidingWindow(10, '10 s'),
analytics: true,
prefix: 'ratelimit',
})
export async function POST(request: Request) {
const identifier = request.headers.get('x-forwarded-for') ?? 'anonymous'
const { success, limit, remaining, reset } = await ratelimit.limit(identifier)
if (!success) {
return new Response('Too many requests', {
status: 429,
headers: {
'RateLimit-Limit': String(limit),
'RateLimit-Remaining': String(remaining),
'RateLimit-Reset': String(Math.ceil((reset - Date.now()) / 1000)),
},
})
}
// ...
}Redis.fromEnv() reads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN. CapyDB publishes
CAPYDB_KV_REST_URL and CAPYDB_KV_REST_TOKEN, so construct the client explicitly as above.
Choosing an algorithm
| Limiter | Behaviour | Cost per check |
|---|---|---|
Ratelimit.fixedWindow(n, '10 s') | Counts per fixed window; allows a burst of 2n across a window boundary | 1 command |
Ratelimit.slidingWindow(n, '10 s') | Weights the previous window, so the boundary burst disappears | 2 commands |
Ratelimit.tokenBucket(refill, '10 s', capacity) | Allows a burst up to capacity, then refills steadily | 1 script |
slidingWindow is the right default: it costs one extra command and removes the burst that makes
fixedWindow surprising in production. Reach for tokenBucket when a burst is a feature - a client
that batches, then goes quiet.
Where the limiter runs matters
A rate-limit check is a blocking round trip in front of work the user is waiting for. Its latency is added to every request, including the ones you allow.
A store is placed in the same region as its project, so if your application runs in that region the
check is a same-datacenter round trip. If your application runs on the other side of the world from
the store, the check costs that round trip - and a limiter that adds 100 ms to every request is
worse than the abuse it prevents. Put the store, the application and the database in one region, or
use ephemeralCache (below) to absorb the repeats.
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '10 s'),
// Denies within the current process without a round trip, once an identifier
// is already known to be over its limit. Per-instance, so it is an
// optimisation, not the limit itself.
ephemeralCache: new Map(),
})Failing open, deliberately
If the store is unreachable, limit() rejects. Decide which way that should fail, and write it down
- the default of letting the exception escape means an unreachable limiter takes your endpoint down with it:
let allowed = true
try {
allowed = (await ratelimit.limit(identifier)).success
} catch {
// Fail open: a limiter outage degrades protection, it does not deny service.
// Invert this for endpoints where an unmetered request is worse than an error.
allowed = true
}Multiple limits
Separate limiters need separate prefixes, or they share a keyspace and count each other's requests:
const perIp = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(100, '60 s'), prefix: 'ip' })
const perUser = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(20, '60 s'), prefix: 'user' })Capacity
Rate-limit keyspaces are small: a counter and a TTL per identifier. The entry plan's 32 MB holds a large number of live identifiers, and expired ones are reclaimed.
Every key @upstash/ratelimit writes carries a TTL, which is exactly what the store's volatile-lru
eviction policy needs: if a store does fill, the least recently used counters are discarded and
writes keep succeeding. For a limiter that means the oldest identifiers get a fresh allowance, not
that the endpoint breaks. Sharing the store with cache entries that have no TTL is what would
break it - those cannot be evicted. See Capacity.
K/V
CapyDB Knight/Valkyrie - a managed key-value store and rate limiter in its own isolated cell, compatible with the Upstash REST protocol and ordinary Redis® OSS clients.
Clients
Connecting to a CapyDB K/V store over the Upstash REST protocol or RESP - @upstash/redis, @capydb/kv, ioredis, redis-py, go-redis and redis-cli.