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.
|
* Utilise pour : cache rows Baserow, idempotence webhooks, rate limiting.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import IORedis from 'ioredis';
|
import IORedis from 'ioredis';
|
||||||
import type { Redis } from 'ioredis';
|
import type { Redis } from 'ioredis';
|
||||||
import type { Logger } from 'pino';
|
import type { Logger } from 'pino';
|
||||||
|
|
@ -94,7 +95,8 @@ export class RedisCache {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const windowKey = `bridge:rate:${key}`;
|
const windowKey = `bridge:rate:${key}`;
|
||||||
const multi = this.client.multi();
|
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.zremrangebyscore(windowKey, 0, now - windowSeconds * 1000);
|
||||||
multi.zcard(windowKey);
|
multi.zcard(windowKey);
|
||||||
multi.expire(windowKey, windowSeconds);
|
multi.expire(windowKey, windowSeconds);
|
||||||
|
|
|
||||||
|
|
@ -184,9 +184,7 @@ describe('RedisCache integration', () => {
|
||||||
const key = `rl:${crypto.randomUUID()}`;
|
const key = `rl:${crypto.randomUUID()}`;
|
||||||
|
|
||||||
const r1 = await cache.checkRateLimit(key, 5, 10);
|
const r1 = await cache.checkRateLimit(key, 5, 10);
|
||||||
await new Promise((r) => setTimeout(r, 2));
|
|
||||||
const r2 = await cache.checkRateLimit(key, 5, 10);
|
const r2 = await cache.checkRateLimit(key, 5, 10);
|
||||||
await new Promise((r) => setTimeout(r, 2));
|
|
||||||
const r3 = await cache.checkRateLimit(key, 5, 10);
|
const r3 = await cache.checkRateLimit(key, 5, 10);
|
||||||
|
|
||||||
expect(r1).toBe(true);
|
expect(r1).toBe(true);
|
||||||
|
|
@ -196,18 +194,27 @@ describe('RedisCache integration', () => {
|
||||||
|
|
||||||
it('refuse les requetes au-dela de la limite', async () => {
|
it('refuse les requetes au-dela de la limite', async () => {
|
||||||
const key = `rl-deny:${crypto.randomUUID()}`;
|
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++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
await cache.checkRateLimit(key, 3, 10);
|
await cache.checkRateLimit(key, 3, 10);
|
||||||
await new Promise((r) => setTimeout(r, 2));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const overLimit = await cache.checkRateLimit(key, 3, 10);
|
const overLimit = await cache.checkRateLimit(key, 3, 10);
|
||||||
|
|
||||||
expect(overLimit).toBe(false);
|
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', () => {
|
describe('healthCheck', () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue