refactor(api): add redis-backed rate limiting fallback

This commit is contained in:
2026-03-30 23:23:56 +02:00
parent bcfb18393e
commit ef5e8016a4
9 changed files with 357 additions and 61 deletions
@@ -204,9 +204,9 @@ function createMissingApprovalTableError() {
describe("assistant router tool gating", () => {
let approvalStore = createApprovalStoreMock();
beforeEach(() => {
beforeEach(async () => {
approvalStore = createApprovalStoreMock();
apiRateLimiter.reset();
await apiRateLimiter.reset();
resetAssistantApprovalStorageWarningStateForTests();
});
@@ -148,10 +148,10 @@ function createToolContext(
}
describe("assistant import/export and dispo tools", () => {
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
vi.unstubAllEnvs();
apiRateLimiter.reset();
await apiRateLimiter.reset();
totpValidateMock.mockReset();
vi.mocked(approveEstimateVersion).mockReset();
vi.mocked(cloneEstimate).mockReset();
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("rate limiter", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-30T10:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
vi.resetModules();
vi.unmock("ioredis");
vi.unstubAllEnvs();
});
it("enforces limits and reset in memory mode", async () => {
const { createRateLimiter } = await import("../middleware/rate-limit.js");
const limiter = createRateLimiter(60_000, 2, { backend: "memory", name: "memory-test" });
const first = await limiter("user-1");
const second = await limiter("user-1");
const third = await limiter("user-1");
expect(first.allowed).toBe(true);
expect(first.remaining).toBe(1);
expect(second.allowed).toBe(true);
expect(second.remaining).toBe(0);
expect(third.allowed).toBe(false);
expect(third.remaining).toBe(0);
await limiter.reset();
const afterReset = await limiter("user-1");
expect(afterReset.allowed).toBe(true);
expect(afterReset.remaining).toBe(1);
});
it("uses a shared Redis counter when Redis mode is enabled", async () => {
const store = new Map<string, { count: number; resetAt: number }>();
const delMock = vi.fn(async (...keys: string[]) => {
for (const key of keys) {
store.delete(key);
}
return keys.length;
});
vi.doMock("ioredis", () => ({
Redis: vi.fn().mockImplementation(() => ({
on: vi.fn(),
eval: vi.fn(async (_script: string, _numKeys: number, key: string, windowMsValue: string) => {
const now = Date.now();
const windowMs = Number(windowMsValue);
const existing = store.get(key);
if (!existing || existing.resetAt <= now) {
store.set(key, { count: 1, resetAt: now + windowMs });
} else {
existing.count += 1;
}
const current = store.get(key)!;
return [current.count, current.resetAt - now];
}),
scan: vi.fn(async () => ["0", [...store.keys()]]),
del: delMock,
})),
}));
const { createRateLimiter } = await import("../middleware/rate-limit.js");
const limiter = createRateLimiter(60_000, 2, {
backend: "redis",
redisUrl: "redis://test",
name: "redis-test",
});
const first = await limiter("user-1");
const second = await limiter("user-1");
const third = await limiter("user-1");
expect(first.allowed).toBe(true);
expect(second.allowed).toBe(true);
expect(third.allowed).toBe(false);
expect(third.remaining).toBe(0);
await limiter.reset();
expect(delMock).toHaveBeenCalled();
const afterReset = await limiter("user-1");
expect(afterReset.allowed).toBe(true);
expect(afterReset.remaining).toBe(1);
});
it("falls back to in-memory counters when Redis is unavailable", async () => {
vi.doMock("ioredis", () => ({
Redis: vi.fn().mockImplementation(() => ({
on: vi.fn(),
eval: vi.fn(async () => {
throw new Error("redis down");
}),
scan: vi.fn(async () => ["0", []]),
del: vi.fn(async () => 0),
})),
}));
const { createRateLimiter } = await import("../middleware/rate-limit.js");
const limiter = createRateLimiter(60_000, 2, {
backend: "redis",
redisUrl: "redis://test",
name: "redis-fallback-test",
});
const first = await limiter("user-1");
const second = await limiter("user-1");
const third = await limiter("user-1");
expect(first.allowed).toBe(true);
expect(second.allowed).toBe(true);
expect(third.allowed).toBe(false);
expect(third.remaining).toBe(0);
});
});