// Rate limiter abstraction with two implementations: // - memory: per-process fixed window (the original behavior) // - redis: shared fixed window across all instances (INCR + PEXPIRE) // // Selection happens once based on REDIS_URL. On any Redis error the limiter // fails open (allows the request) so a Redis blip never takes the API down. import { getRedis, isRedisEnabled } from '../redis.js'; export interface RateLimitResult { allowed: boolean; retryAfter?: number; } export interface RateLimiter { readonly backend: 'memory' | 'redis'; consume(key: string, max: number, windowMs: number): Promise; } // ==================== Memory implementation ==================== interface Bucket { count: number; resetAt: number; } class MemoryRateLimiter implements RateLimiter { readonly backend = 'memory' as const; private buckets = new Map(); constructor() { // Periodically drop expired buckets so the Map does not grow unbounded. const cleanup = setInterval(() => { const now = Date.now(); for (const [key, bucket] of this.buckets) { if (now > bucket.resetAt) this.buckets.delete(key); } }, 60_000); (cleanup as any).unref?.(); } async consume(key: string, max: number, windowMs: number): Promise { const now = Date.now(); const bucket = this.buckets.get(key); if (!bucket || now > bucket.resetAt) { this.buckets.set(key, { count: 1, resetAt: now + windowMs }); return { allowed: true }; } bucket.count++; if (bucket.count > max) { return { allowed: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) }; } return { allowed: true }; } } // ==================== Redis implementation ==================== class RedisRateLimiter implements RateLimiter { readonly backend = 'redis' as const; async consume(key: string, max: number, windowMs: number): Promise { const redis = getRedis(); if (!redis) return { allowed: true }; const redisKey = `rl:${key}`; try { const count = await redis.incr(redisKey); if (count === 1) { // First hit in this window: set the expiry that defines the window. await redis.pexpire(redisKey, windowMs); } if (count > max) { const ttl = await redis.pttl(redisKey); const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(windowMs / 1000); return { allowed: false, retryAfter }; } return { allowed: true }; } catch (err: any) { // Fail open: never block traffic because Redis is unavailable. console.error('[rateLimiter] redis error, allowing request:', err?.message || err); return { allowed: true }; } } } // ==================== Selection ==================== let instance: RateLimiter | null = null; export function getRateLimiter(): RateLimiter { if (!instance) { instance = isRedisEnabled() ? new RedisRateLimiter() : new MemoryRateLimiter(); } return instance; }