feat: añade limitador de tasa por usuario (15/min) y tests
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>pull/1/head
parent
432409e246
commit
9668802cbe
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* RateLimiter - Token Bucket en memoria por usuario.
|
||||
* - Límite global por remitente (ID normalizado).
|
||||
* - Capacidad y tasa configurables por entorno:
|
||||
* - RATE_LIMIT_PER_MIN: tokens que se recargan por minuto (default 15)
|
||||
* - RATE_LIMIT_BURST: capacidad del bucket (default = RATE_LIMIT_PER_MIN)
|
||||
* - Desactivación en tests: la integración en server evita usar el limiter con NODE_ENV='test'.
|
||||
*/
|
||||
export class RateLimiter {
|
||||
private static readonly buckets = new Map<string, { tokens: number; last: number }>();
|
||||
private static readonly lastNotifyAt = new Map<string, number>();
|
||||
private static readonly NOTIFY_COOLDOWN_MS = 60_000; // 1 minuto
|
||||
|
||||
private static get ratePerMin(): number {
|
||||
const v = Number(process.env.RATE_LIMIT_PER_MIN);
|
||||
return Number.isFinite(v) && v > 0 ? v : 15;
|
||||
}
|
||||
|
||||
private static get capacity(): number {
|
||||
const burst = Number(process.env.RATE_LIMIT_BURST);
|
||||
if (Number.isFinite(burst) && burst > 0) return burst;
|
||||
return this.ratePerMin; // por defecto, burst = rate
|
||||
}
|
||||
|
||||
private static get refillPerMs(): number {
|
||||
return this.ratePerMin / 60_000; // tokens por ms
|
||||
}
|
||||
|
||||
static checkAndConsume(userId: string, now: number = Date.now()): boolean {
|
||||
if (!userId) return true; // por seguridad, no bloquear IDs vacíos
|
||||
|
||||
let bucket = this.buckets.get(userId);
|
||||
if (!bucket) {
|
||||
bucket = { tokens: this.capacity, last: now };
|
||||
this.buckets.set(userId, bucket);
|
||||
}
|
||||
|
||||
// Refill lineal en función del tiempo transcurrido
|
||||
const elapsed = Math.max(0, now - bucket.last);
|
||||
if (elapsed > 0) {
|
||||
bucket.tokens = Math.min(this.capacity, bucket.tokens + elapsed * this.refillPerMs);
|
||||
bucket.last = now;
|
||||
}
|
||||
|
||||
if (bucket.tokens >= 1) {
|
||||
bucket.tokens -= 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static shouldNotify(userId: string, now: number = Date.now()): boolean {
|
||||
const last = this.lastNotifyAt.get(userId) || 0;
|
||||
if (now - last >= this.NOTIFY_COOLDOWN_MS) {
|
||||
this.lastNotifyAt.set(userId, now);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Solo para tests
|
||||
static reset(): void {
|
||||
this.buckets.clear();
|
||||
this.lastNotifyAt.clear();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, beforeEach } from 'bun:test';
|
||||
import { RateLimiter } from '../../../src/services/rate-limit';
|
||||
|
||||
describe('RateLimiter - token bucket básico', () => {
|
||||
beforeEach(() => {
|
||||
RateLimiter.reset();
|
||||
// No dependemos de NODE_ENV aquí; testea la clase aislada.
|
||||
process.env.RATE_LIMIT_PER_MIN = '15';
|
||||
process.env.RATE_LIMIT_BURST = '15';
|
||||
});
|
||||
|
||||
it('permite hasta el burst inicial y bloquea al exceder', () => {
|
||||
const user = '1234567890';
|
||||
const t0 = 0;
|
||||
|
||||
// 15 primeros deben pasar
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const ok = RateLimiter.checkAndConsume(user, t0);
|
||||
expect(ok).toBe(true);
|
||||
}
|
||||
|
||||
// 16º sin tiempo transcurrido debe bloquear
|
||||
const denied = RateLimiter.checkAndConsume(user, t0);
|
||||
expect(denied).toBe(false);
|
||||
});
|
||||
|
||||
it('recupera tokens con el tiempo (refill lineal por minuto)', () => {
|
||||
const user = '1234567890';
|
||||
const t0 = 0;
|
||||
|
||||
// Agotar bucket
|
||||
for (let i = 0; i < 15; i++) {
|
||||
expect(RateLimiter.checkAndConsume(user, t0)).toBe(true);
|
||||
}
|
||||
expect(RateLimiter.checkAndConsume(user, t0)).toBe(false); // agotado
|
||||
|
||||
// Tras 60s debe haberse recuperado capacidad suficiente para permitir de nuevo
|
||||
const t1 = 60_000;
|
||||
expect(RateLimiter.checkAndConsume(user, t1)).toBe(true);
|
||||
|
||||
// Y puede consumir varios más (hasta ~15 por minuto)
|
||||
let count = 1;
|
||||
while (RateLimiter.checkAndConsume(user, t1)) {
|
||||
count++;
|
||||
if (count > 20) break; // seguridad
|
||||
}
|
||||
expect(count).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('shouldNotify limita el aviso a como mucho 1/min por usuario', () => {
|
||||
const user = 'u1';
|
||||
const t0 = 0;
|
||||
expect(RateLimiter.shouldNotify(user, t0)).toBe(true); // primera vez notifica
|
||||
expect(RateLimiter.shouldNotify(user, t0 + 10_000)).toBe(false); // dentro de la ventana
|
||||
expect(RateLimiter.shouldNotify(user, t0 + 60_000)).toBe(true); // pasado el cooldown
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue