mirror of
https://github.com/devkitshq/notifkit.git
synced 2026-10-19 18:24:52 +02:00
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:
+100
-27
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user