fix(redis-cache): membre ZSET unique pour eviter collision sub-ms dans checkRateLimit

Date.now() seul collisionne sur appels concurrents dans la meme milliseconde,
ce qui faisait compter 1 entry au lieu de N dans la fenetre glissante.
Suffixe randomUUID pour garantir l'unicite du membre. Ajoute test burst 10x
qui prouve les 5 allowed + 5 denied attendus.
This commit is contained in:
Corentin JOGUET 2026-05-07 20:38:07 +02:00
parent 1528017bab
commit 1cdb1b6ca4
2 changed files with 16 additions and 7 deletions

View file

@ -3,6 +3,7 @@
* Utilise pour : cache rows Baserow, idempotence webhooks, rate limiting.
*/
import { randomUUID } from 'node:crypto';
import IORedis from 'ioredis';
import type { Redis } from 'ioredis';
import type { Logger } from 'pino';
@ -94,7 +95,8 @@ export class RedisCache {
const now = Date.now();
const windowKey = `bridge:rate:${key}`;
const multi = this.client.multi();
multi.zadd(windowKey, now, `${now}`);
// member unique : timestamp seul collisionne sur appels sub-ms (1 entry au lieu de N)
multi.zadd(windowKey, now, `${now}-${randomUUID()}`);
multi.zremrangebyscore(windowKey, 0, now - windowSeconds * 1000);
multi.zcard(windowKey);
multi.expire(windowKey, windowSeconds);

View file

@ -184,9 +184,7 @@ describe('RedisCache integration', () => {
const key = `rl:${crypto.randomUUID()}`;
const r1 = await cache.checkRateLimit(key, 5, 10);
await new Promise((r) => setTimeout(r, 2));
const r2 = await cache.checkRateLimit(key, 5, 10);
await new Promise((r) => setTimeout(r, 2));
const r3 = await cache.checkRateLimit(key, 5, 10);
expect(r1).toBe(true);
@ -196,18 +194,27 @@ describe('RedisCache integration', () => {
it('refuse les requetes au-dela de la limite', async () => {
const key = `rl-deny:${crypto.randomUUID()}`;
// Note: les appels DOIVENT etre espaces de >= 1ms car le ZSET utilise
// `${Date.now()}` comme membre — sinon collision sur meme ms (valeur unique).
// Saturer la fenetre.
for (let i = 0; i < 3; i++) {
await cache.checkRateLimit(key, 3, 10);
await new Promise((r) => setTimeout(r, 2));
}
const overLimit = await cache.checkRateLimit(key, 3, 10);
expect(overLimit).toBe(false);
});
it('compte chaque appel meme en burst sub-ms (membre ZSET unique)', async () => {
const key = `rl-burst:${crypto.randomUUID()}`;
const results = await Promise.all(
Array.from({ length: 10 }, () => cache.checkRateLimit(key, 5, 10)),
);
const allowed = results.filter((r) => r === true).length;
const denied = results.filter((r) => r === false).length;
expect(allowed).toBe(5);
expect(denied).toBe(5);
});
});
describe('healthCheck', () => {