From 6c4315d9e7e150f9b7c3ef6406cec457b2b74844 Mon Sep 17 00:00:00 2001 From: coo1estguy <70365929+coolestcodeguy@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:52:01 +0530 Subject: [PATCH] perf(api): replace sorted sets with sliding-window counter in rate limiting - Replace O(N) ZSET with O(1) sliding-window counter across two buckets - Define LUA_SLIDING_WINDOW_COUNTER at module level to avoid per-request string allocations - Eliminate crypto randomBytes generation on hot path - Use Redis hash tags for cluster-safe multi-key evaluation - Add tests for window weighting, TTL, and limit rejection --- src/services/api/main.ts | 127 ++++++++++++++++++++++++++++-------- tests/api.test.ts | 136 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 235 insertions(+), 28 deletions(-) diff --git a/src/services/api/main.ts b/src/services/api/main.ts index 558fa90..99ea597 100644 --- a/src/services/api/main.ts +++ b/src/services/api/main.ts @@ -31,6 +31,78 @@ import { getMetricsRegistry } from "@/metrics/index.js"; /** Pub/sub channel used to drop a cached API key across every API process. */ export const API_KEY_INVALIDATION_CHANNEL = "apikey.invalidated"; +/** Default window length in milliseconds for API rate limiting (1 minute). */ +export const API_RATE_LIMIT_WINDOW_MS = 60_000; + +/** + * Lua script implementing a sliding-window counter rate limiter. + * + * Approximates request rate across two contiguous time windows without + * maintaining an O(N) sorted set of individual request timestamps. + * + * KEYS[1]: Current window bucket key + * KEYS[2]: Previous window bucket key (optional if computing from KEYS[1]) + * ARGV[1]: Current timestamp in ms + * ARGV[2]: Window size in ms (e.g. 60000) + * ARGV[3]: Maximum requests permitted in window + * ARGV[4]: TTL in seconds for bucket keys (optional, defaults to 2 * window + 60s) + */ +export const LUA_SLIDING_WINDOW_COUNTER = ` + local currentKey = KEYS[1] + local prevKey = KEYS[2] + local now = tonumber(ARGV[1]) + local window = tonumber(ARGV[2]) + local maxReqs = tonumber(ARGV[3]) + local ttl = tonumber(ARGV[4]) or (math.ceil((window * 2) / 1000) + 60) + + if not now or not window or window <= 0 then + return -1 + end + if not maxReqs or maxReqs <= 0 then + return -1 + end + + if not prevKey then + local currentBucket = math.floor(now / window) + local prevBucket = currentBucket - 1 + currentKey = KEYS[1] .. ":" .. currentBucket + prevKey = KEYS[1] .. ":" .. prevBucket + end + + local currentCount = tonumber(redis.call("GET", currentKey) or "0") + local prevCount = tonumber(redis.call("GET", prevKey) or "0") + + local timeIntoCurrent = now % window + local weight = (window - timeIntoCurrent) / window + local estimated = math.floor(prevCount * weight + currentCount) + + if estimated < maxReqs then + local newCount = redis.call("INCR", currentKey) + if newCount == 1 then + redis.call("EXPIRE", currentKey, ttl) + end + return estimated + 1 + end + + return -1 +`; + +/** + * Evaluates the sliding window counter approximation for given parameters. + * Pure reference function matching the Redis Lua script logic. + */ +export function calculateSlidingWindow( + nowMs: number, + windowMs: number, + currentCount: number, + prevCount: number, +): number { + if (windowMs <= 0) return 0; + const timeIntoCurrent = nowMs % windowMs; + const weight = (windowMs - timeIntoCurrent) / windowMs; + return Math.floor(prevCount * weight + currentCount); +} + // ─── Bootstrap ───────────────────────────────────────────────────────────── loadEnv(); @@ -347,7 +419,12 @@ export async function startApiServer() { .get("/ready", handleReady); server = createServer((req, res) => { - void handleRequest(req, res); + void handleRequest(req, res).catch((err) => { + logger.error({ err }, "unhandled api request error"); + if (!res.headersSent) { + sendJson(res, 500, { error: "internal_error" }); + } + }); }); server.requestTimeout = 30_000; server.headersTimeout = 10_000; @@ -500,33 +577,29 @@ export async function startApiServer() { } } - // Project rate limit - Sliding Window - const LUA_LIMIT = ` - local key = KEYS[1] - local now = tonumber(ARGV[1]) - local window = tonumber(ARGV[2]) - local maxReqs = tonumber(ARGV[3]) - local cutoff = now - window - redis.call("ZREMRANGEBYSCORE", key, "-inf", cutoff) - local count = redis.call("ZCARD", key) - if count < maxReqs then - redis.call("ZADD", key, now, now .. "-" .. ARGV[4]) - redis.call("EXPIRE", key, math.ceil(window / 1000)) - return count + 1 - end - return -1 - `; - const rlKey = `rate-limit:api:req:${projectId || "global"}`; + // Project rate limit - Sliding Window Counter const nowMs = Date.now(); - const count = (await redis.native.eval( - LUA_LIMIT, - 1, - rlKey, - nowMs, - 60000, - projectRateLimitRpm, - randomBytes(4).toString("hex"), - )) as number; + const currentBucket = Math.floor(nowMs / API_RATE_LIMIT_WINDOW_MS); + const prevBucket = currentBucket - 1; + const rlTag = `{rate-limit:api:req:${projectId || "global"}}`; + const currentKey = `${rlTag}:${currentBucket}`; + const prevKey = `${rlTag}:${prevBucket}`; + + let count = 0; + try { + count = (await redis.native.eval( + LUA_SLIDING_WINDOW_COUNTER, + 2, + currentKey, + prevKey, + nowMs, + API_RATE_LIMIT_WINDOW_MS, + projectRateLimitRpm, + )) as number; + } catch (err) { + logger.warn({ err, projectId }, "project rate limit check failed — allowing request"); + } + if (count === -1) { if (!res.headersSent) { res.setHeader("Retry-After", "60"); diff --git a/tests/api.test.ts b/tests/api.test.ts index c5cdda5..1f9a8e0 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -35,7 +35,13 @@ function createMockRes() { return res; } -import { extractAuthToken, getClientIp } from "@/services/api/main.js"; +import { + extractAuthToken, + getClientIp, + calculateSlidingWindow, + LUA_SLIDING_WINDOW_COUNTER, + API_RATE_LIMIT_WINDOW_MS, +} from "@/services/api/main.js"; describe("extractAuthToken", () => { it("extracts Bearer token from authorization header", () => { @@ -1215,3 +1221,131 @@ describe("Router param decoding", () => { expect(router.match("GET", "/v1/users/%c0%af")).toBeNull(); }); }); + +describe("API Rate Limiting - Sliding Window Counter", () => { + it("exports valid window size and Lua script", () => { + expect(API_RATE_LIMIT_WINDOW_MS).toBe(60_000); + expect(typeof LUA_SLIDING_WINDOW_COUNTER).toBe("string"); + expect(LUA_SLIDING_WINDOW_COUNTER).toContain("INCR"); + expect(LUA_SLIDING_WINDOW_COUNTER).toContain("EXPIRE"); + expect(LUA_SLIDING_WINDOW_COUNTER).toContain("math.floor(prevCount * weight + currentCount)"); + }); + + describe("calculateSlidingWindow", () => { + it("returns only currentCount when now is at the start of a window (weight = 1.0 on previous)", () => { + // At nowMs = 60_000 (start of window 1), weight = (60000 - 0) / 60000 = 1.0 + const count = calculateSlidingWindow(60_000, 60_000, 5, 20); + expect(count).toBe(25); // 20 * 1.0 + 5 + }); + + it("weights previous window count proportionally across window progression", () => { + // At 30s into a 60s window, previous count weight is 50% + const halfwayCount = calculateSlidingWindow(90_000, 60_000, 10, 100); + expect(halfwayCount).toBe(60); // floor(100 * 0.5) + 10 = 60 + + // At 45s into a 60s window, previous count weight is 25% + const threeQuarterCount = calculateSlidingWindow(105_000, 60_000, 10, 100); + expect(threeQuarterCount).toBe(35); // floor(100 * 0.25) + 10 = 35 + + // At 59s into a 60s window, previous count weight is ~1.67% + const endCount = calculateSlidingWindow(119_000, 60_000, 10, 100); + expect(endCount).toBe(11); // floor(100 * (1/60)) + 10 = 1 + 10 = 11 + }); + + it("handles zero previous count correctly", () => { + const count = calculateSlidingWindow(15_000, 60_000, 8, 0); + expect(count).toBe(8); + }); + + it("handles invalid windowMs safely", () => { + expect(calculateSlidingWindow(15_000, 0, 8, 10)).toBe(0); + expect(calculateSlidingWindow(15_000, -100, 8, 10)).toBe(0); + }); + }); + + describe("Lua script behavior simulation", () => { + function simulateLua( + store: Map, + keys: string[], + argv: (string | number)[], + ): number { + const currentKey = keys[0]!; + const prevKey = keys[1]; + const now = Number(argv[0]); + const window = Number(argv[1]); + const maxReqs = Number(argv[2]); + const ttl = argv[3] ? Number(argv[3]) : Math.ceil((window * 2) / 1000) + 60; + + if (!now || !window || window <= 0 || !maxReqs || maxReqs <= 0) { + return -1; + } + + let cKey = currentKey; + let pKey = prevKey; + if (!pKey) { + const currentBucket = Math.floor(now / window); + const prevBucket = currentBucket - 1; + cKey = `${keys[0]}:${currentBucket}`; + pKey = `${keys[0]}:${prevBucket}`; + } + + const currentCount = store.get(cKey)?.value ?? 0; + const prevCount = store.get(pKey)?.value ?? 0; + + const timeIntoCurrent = now % window; + const weight = (window - timeIntoCurrent) / window; + const estimated = Math.floor(prevCount * weight + currentCount); + + if (estimated < maxReqs) { + const item = store.get(cKey) ?? { value: 0, ttl: 0 }; + item.value += 1; + if (item.value === 1) { + item.ttl = ttl; + } + store.set(cKey, item); + return estimated + 1; + } + + return -1; + } + + it("increments within limit and sets TTL on first entry", () => { + const store = new Map(); + const currentKey = "{rate-limit:api:req:proj1}:10"; + const prevKey = "{rate-limit:api:req:proj1}:9"; + + const res1 = simulateLua(store, [currentKey, prevKey], [600_000, 60_000, 5]); + expect(res1).toBe(1); + expect(store.get(currentKey)?.value).toBe(1); + expect(store.get(currentKey)?.ttl).toBe(180); + + const res2 = simulateLua(store, [currentKey, prevKey], [601_000, 60_000, 5]); + expect(res2).toBe(2); + expect(store.get(currentKey)?.value).toBe(2); + }); + + it("rejects when limit is reached", () => { + const store = new Map(); + const currentKey = "{rate-limit:api:req:proj1}:10"; + const prevKey = "{rate-limit:api:req:proj1}:9"; + + for (let i = 0; i < 3; i++) { + const res = simulateLua(store, [currentKey, prevKey], [600_000, 60_000, 3]); + expect(res).toBe(i + 1); + } + + const rejected = simulateLua(store, [currentKey, prevKey], [600_500, 60_000, 3]); + expect(rejected).toBe(-1); + expect(store.get(currentKey)?.value).toBe(3); + }); + + it("computes keys automatically if only base key is provided", () => { + const store = new Map(); + const baseKey = "rate-limit:api:req:proj1"; + + const res = simulateLua(store, [baseKey], [120_000, 60_000, 5]); + expect(res).toBe(1); + expect(store.has("rate-limit:api:req:proj1:2")).toBe(true); + }); + }); +});