// Rate limiter abstraction with two implementations: // - memory: per-process fixed window (the original behavior) // - redis: shared fixed window across all instances, implemented as a single // Lua script so INCR and PEXPIRE are atomic (a crash between separate calls // would otherwise strand a counter with no expiry) // // 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 type Redis from 'ioredis'; 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; } export 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 ==================== // Atomically increments the window counter, sets the expiry on the first hit, // and repairs any counter left without a TTL (self-heals keys stranded by the // pre-Lua implementation or a lost PEXPIRE). Returns {count, ttlMs}. export const CONSUME_SCRIPT = ` local count = redis.call('INCR', KEYS[1]) if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end local ttl = redis.call('PTTL', KEYS[1]) if ttl < 0 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) ttl = tonumber(ARGV[1]) end return {count, ttl} `; type RedisWithConsume = Redis & { rlConsume(key: string, windowMs: number): Promise<[number, number]>; }; function withConsumeCommand(redis: Redis): RedisWithConsume { if (typeof (redis as any).rlConsume !== 'function') { // ioredis caches the script SHA and transparently handles NOSCRIPT. redis.defineCommand('rlConsume', { numberOfKeys: 1, lua: CONSUME_SCRIPT }); } return redis as RedisWithConsume; } export 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 }; try { const [count, ttl] = await withConsumeCommand(redis).rlConsume(`rl:${key}`, windowMs); if (count > max) { 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; }