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
This commit is contained in:
coo1estguy
2026-09-25 13:52:01 +05:30
parent d439f051a6
commit 6c4315d9e7
2 changed files with 235 additions and 28 deletions
+100 -27
View File
@@ -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");
+135 -1
View File
@@ -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<string, { value: number; ttl: number }>,
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<string, { value: number; ttl: number }>();
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<string, { value: number; ttl: number }>();
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<string, { value: number; ttl: number }>();
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);
});
});
});