What problem does this solve?
src/lib/rate-limit.ts uses a fixed-window counter (as its own docstring notes:
"If we ever need true sliding window precision, swap the backend without touching
callers"). Fixed windows have a well-known flaw: a client can send limit requests
at the end of window N and another limit at the start of window N+1 — 2x the
intended rate in a sub-second span. For endpoints protected with STRICT (10/60s)
or HOURLY (5/3600s), that doubling defeats the protection these limits exist to
provide (webhook retry, leaderboard scopes, invite sending).
Proposed Solution
Add a sliding-window algorithm behind the existing rateLimit() API so no call
site changes:
- Add a
rateLimitHitSlidingWindow(key, windowSec, limit, now) method to the
CacheBackend interface and implement it in all three backends
(MemoryBackend, UpstashBackend, IoRedisBackend):
- Redis backends: sorted-set approach —
ZREMRANGEBYSCORE to drop timestamps
older than now - windowSec, ZADD the current request, ZCARD to count, and
PEXPIRE the key. Pipeline the commands for atomicity.
- Memory backend: keep a bounded timestamp list per key with the same eviction
logic (deterministic for tests).
- Switch
rateLimit() to use it (keep the fixed-window path available behind a
flag if the maintainer prefers a gradual rollout).
Tests
- Boundary test proving the old 2x burst is now rejected (send
limit at t=59s and
limit at t=61s within a 60s window → second batch blocked).
- Eviction test (old hits age out and free capacity).
resetAt/remaining correctness across all three backends.
What problem does this solve?
src/lib/rate-limit.tsuses a fixed-window counter (as its own docstring notes:"If we ever need true sliding window precision, swap the backend without touching
callers"). Fixed windows have a well-known flaw: a client can send
limitrequestsat the end of window N and another
limitat the start of window N+1 — 2x theintended rate in a sub-second span. For endpoints protected with
STRICT(10/60s)or
HOURLY(5/3600s), that doubling defeats the protection these limits exist toprovide (webhook retry, leaderboard scopes, invite sending).
Proposed Solution
Add a sliding-window algorithm behind the existing
rateLimit()API so no callsite changes:
rateLimitHitSlidingWindow(key, windowSec, limit, now)method to theCacheBackendinterface and implement it in all three backends(
MemoryBackend,UpstashBackend,IoRedisBackend):ZREMRANGEBYSCOREto drop timestampsolder than
now - windowSec,ZADDthe current request,ZCARDto count, andPEXPIREthe key. Pipeline the commands for atomicity.logic (deterministic for tests).
rateLimit()to use it (keep the fixed-window path available behind aflag if the maintainer prefers a gradual rollout).
Tests
limitat t=59s andlimitat t=61s within a 60s window → second batch blocked).resetAt/remainingcorrectness across all three backends.