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:
parent
1528017bab
commit
1cdb1b6ca4
2 changed files with 16 additions and 7 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue