From 1cdb1b6ca48c019064801e76e5107608a21a1d47 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Thu, 7 May 2026 20:38:07 +0200 Subject: [PATCH] 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. --- bridge/src/adapters/redis-cache.ts | 4 +++- bridge/tests/integration/redis-cache.test.ts | 19 +++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/bridge/src/adapters/redis-cache.ts b/bridge/src/adapters/redis-cache.ts index 55653dc..01d5abf 100644 --- a/bridge/src/adapters/redis-cache.ts +++ b/bridge/src/adapters/redis-cache.ts @@ -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); diff --git a/bridge/tests/integration/redis-cache.test.ts b/bridge/tests/integration/redis-cache.test.ts index 1438d33..316e213 100644 --- a/bridge/tests/integration/redis-cache.test.ts +++ b/bridge/tests/integration/redis-cache.test.ts @@ -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', () => {