import { Redis } from '@upstash/redis'; import { RateLimitTimeoutError } from '../utils/error.js'; import { RATE_LIMIT_RULES, categorizeRequest, type RateLimitRule } from './config.js'; export interface RateLimiterRedisConfig { url: string; token: string; } export interface RateLimiterLogger { warn: (message: string, ...args: unknown[]) => void; error: (message: string, ...args: unknown[]) => void; } export interface RateLimiterConfig { redis: RateLimiterRedisConfig; hostId?: string; timeoutMs?: number; safetyMargin?: number; logger?: RateLimiterLogger; } const DEFAULT_HOST_ID = 'victor'; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_SAFETY_MARGIN = 0.9; const CONVERSATIONS_5S_SAFETY_MARGIN = 0.6; const POLL_INTERVAL_MS = 1_000; export class RateLimiter { private readonly redis: Redis; private readonly hostId: string; private readonly timeoutMs: number; private readonly safetyMargin: number; private readonly logger?: RateLimiterLogger; constructor(configOrRedis: RateLimiterConfig | Redis, options?: { hostId?: string; timeoutMs?: number; safetyMargin?: number; logger?: RateLimiterLogger }) { if (configOrRedis instanceof Redis) { this.redis = configOrRedis; this.hostId = options?.hostId ?? DEFAULT_HOST_ID; this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; this.safetyMargin = options?.safetyMargin ?? DEFAULT_SAFETY_MARGIN; this.logger = options?.logger; } else { this.redis = new Redis({ url: configOrRedis.redis.url, token: configOrRedis.redis.token, }); this.hostId = configOrRedis.hostId ?? DEFAULT_HOST_ID; this.timeoutMs = configOrRedis.timeoutMs ?? DEFAULT_TIMEOUT_MS; this.safetyMargin = configOrRedis.safetyMargin ?? DEFAULT_SAFETY_MARGIN; this.logger = configOrRedis.logger; } } /** * Create a RateLimiter using an existing @upstash/redis instance. * Use this to share the Redis connection with other parts of the app. */ static fromRedisInstance( redis: Redis, options?: { hostId?: string; timeoutMs?: number; safetyMargin?: number; logger?: RateLimiterLogger } ): RateLimiter { return new RateLimiter(redis, options); } /** * Acquire rate limit capacity for a request. * Blocks (polls) until capacity is available or timeout is reached. * Fails closed if Redis is unavailable — throws RateLimitTimeoutError to prevent * unthrottled requests from overwhelming the upstream API. */ async acquire(method: string, url: string): Promise { const category = categorizeRequest(method, url); const startTime = Date.now(); let queued = false; while (true) { try { const allowed = await this.tryAcquire(category); if (allowed) return; } catch (err) { if (err instanceof RateLimitTimeoutError) throw err; // Redis error — fail closed to prevent unthrottled requests this.logger?.error(`Rate limiter Redis error, failing closed`, err); throw new RateLimitTimeoutError(category, this.timeoutMs); } if (!queued) { this.logger?.warn(`Rate limit reached for ${category}, queuing request (timeout: ${this.timeoutMs}ms)`); queued = true; } const elapsed = Date.now() - startTime; if (elapsed + POLL_INTERVAL_MS > this.timeoutMs) { this.logger?.error(`Rate limit timeout for ${category} after ${this.timeoutMs}ms`); throw new RateLimitTimeoutError(category, this.timeoutMs); } await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); } } /** * Optimistic add-then-check-rollback. * 1. ZADD our entry to all relevant sorted sets * 2. ZCARD to count entries in each window (includes our entry) * 3. If any window exceeds limit → ZREM our entry from all sets (rollback) */ private async tryAcquire(category: string): Promise { const now = Date.now(); const memberId = `${now}:${Math.random().toString(36).slice(2, 10)}`; // Collect all rules to check const checks = this.getChecks(category); // Step 1: Clean expired entries + optimistically add our entry const addPipeline = this.redis.pipeline(); for (const { key, rule } of checks) { const windowStart = now - rule.windowMs; addPipeline.zremrangebyscore(key, 0, windowStart); addPipeline.zadd(key, { score: now, member: memberId }); addPipeline.zcard(key); // Auto-expire key slightly after window to prevent leaks addPipeline.pexpire(key, rule.windowMs + 60_000); } const results = await addPipeline.exec(); // Step 2: Check if any window exceeds its limit // Results order per check: [zremrangebyscore, zadd, zcard, pexpire] let overLimit = false; for (let i = 0; i < checks.length; i++) { const zcardIndex = i * 4 + 2; // 3rd result per check group const count = results[zcardIndex] as number; const effectiveLimit = Math.floor(checks[i].rule.limit * checks[i].margin); if (count > effectiveLimit) { overLimit = true; break; } } // Step 3: If over limit, rollback — remove our entry from all sets if (overLimit) { const rollbackPipeline = this.redis.pipeline(); for (const { key } of checks) { rollbackPipeline.zrem(key, memberId); } await rollbackPipeline.exec(); return false; } return true; } private getChecks(category: string): Array<{ key: string; rule: RateLimitRule; margin: number }> { const checks: Array<{ key: string; rule: RateLimitRule; margin: number }> = []; // Host-level global limits (apply to all requests) for (const rule of RATE_LIMIT_RULES.host) { checks.push({ key: `hostex:rate:${this.hostId}:global:${rule.windowMs}`, rule, margin: this.safetyMargin, }); } // Host-level per-endpoint limits (if categorized) if (category !== 'default') { for (const rule of RATE_LIMIT_RULES.hostPerEndpoint) { checks.push({ key: `hostex:rate:${this.hostId}:${category}:host:${rule.windowMs}`, rule, margin: this.safetyMargin, }); } } // Endpoint-specific limits const endpointRules = RATE_LIMIT_RULES.endpoints[category]; if (endpointRules) { for (const rule of endpointRules) { // Conversations 5/5sec uses a tighter margin due to low absolute limit + race window const margin = (category === 'conversations' && rule.windowMs === 5_000) ? CONVERSATIONS_5S_SAFETY_MARGIN : this.safetyMargin; checks.push({ key: `hostex:rate:${this.hostId}:${category}:${rule.windowMs}`, rule, margin, }); } } return checks; } /** * Clean up test keys. Only use in tests. */ async cleanup(hostId: string): Promise { try { const prefixes = ['global', 'availabilities', 'listings', 'reservations', 'conversations']; // Derive windows from RATE_LIMIT_RULES to stay in sync const allWindows = new Set(); for (const rule of [...RATE_LIMIT_RULES.host, ...RATE_LIMIT_RULES.hostPerEndpoint]) { allWindows.add(rule.windowMs); } for (const rules of Object.values(RATE_LIMIT_RULES.endpoints)) { for (const rule of rules) { allWindows.add(rule.windowMs); } } const pipeline = this.redis.pipeline(); for (const prefix of prefixes) { for (const window of allWindows) { pipeline.del(`hostex:rate:${hostId}:${prefix}:${window}`); pipeline.del(`hostex:rate:${hostId}:${prefix}:host:${window}`); } } await pipeline.exec(); } catch { // Ignore cleanup errors } } }