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