import { headers } from "next/headers"; import { resolveClientIp, UNKNOWN_CLIENT_IP } from "@/lib/client-ip"; import { logger } from "@/lib/logger"; import { redis } from "@/lib/redis"; type Bucket = { count: number; resetAt: number }; const buckets = new Map(); export interface RateLimitResult { ok: boolean; retryAfter: number; } const CLEANUP_INTERVAL_MS = 300_000; const MAX_BUCKETS = 10_000; let lastCleanup = Date.now(); let redisFailWarned = false; function cleanup(): void { const now = Date.now(); if (now - lastCleanup < CLEANUP_INTERVAL_MS) return; lastCleanup = now; for (const [k, b] of buckets) { if (now >= b.resetAt) buckets.delete(k); } if (buckets.size > MAX_BUCKETS) { const sorted = [...buckets.entries()].sort( (a, b) => a[1].resetAt - b[1].resetAt, ); const keysToRemove = sorted .slice(0, Math.floor(sorted.length * 0.2)) .map((entry) => entry[0]); for (const key of keysToRemove) buckets.delete(key); } } export async function rateLimit( key: string, limit: number, windowMs: number, ): Promise { const now = Date.now(); if (redis) { try { const windowKey = `ratelimit:${key}`; const current = await redis.incr(windowKey); if (current === 1) await redis.pexpire(windowKey, windowMs); const ttl = current === 1 ? windowMs : Math.max(0, await redis.pttl(windowKey)); if (current > limit) { return { ok: false, retryAfter: Math.ceil(ttl / 1000) }; } return { ok: true, retryAfter: 0 }; } catch { // Redis unavailable — fall through to in-memory if (process.env.NODE_ENV === "production" && !redisFailWarned) { redisFailWarned = true; logger.error( "[rate-limit] Redis error — falling back to in-process buckets. Limits are not shared across instances until Redis recovers.", ); } } } cleanup(); const windowKey = `mem:${key}`; const bucket = buckets.get(windowKey); if (!bucket || now >= bucket.resetAt) { buckets.set(windowKey, { count: 1, resetAt: now + windowMs }); return { ok: true, retryAfter: 0 }; } const newCount = bucket.count + 1; if (newCount > limit) { return { ok: false, retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)), }; } bucket.count = newCount; return { ok: true, retryAfter: 0 }; } export async function clientIp(): Promise { try { return resolveClientIp(await headers()); } catch { return UNKNOWN_CLIENT_IP; } }