/** * llmindex.io — Redis fixed-window rate limiting for /api/v1 * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * License: Proprietary — © Simon-Pierre Boucher, all rights reserved * * 60 req/min anonymous, 600 req/min with a valid API key (§8). * Fails open if Redis is unavailable (availability over strictness). */ import 'server-only'; import Redis from 'ioredis'; import { NextResponse, type NextRequest } from 'next/server'; const ANON_LIMIT = 60; const KEYED_LIMIT = 600; const WINDOW_S = 60; let redis: Redis | null | undefined; function getRedis(): Redis | null { if (redis !== undefined) return redis; const url = process.env.REDIS_URL; if (!url) { redis = null; return redis; } redis = new Redis(url, { maxRetriesPerRequest: 1, lazyConnect: false, enableOfflineQueue: false }); redis.on('error', () => { /* logged by ioredis once; keep the API failing open */ }); return redis; } function validApiKey(req: NextRequest): boolean { const key = req.headers.get('x-api-key'); if (!key) return false; const configured = (process.env.API_KEYS ?? '').split(',').map((k) => k.trim()).filter(Boolean); return configured.includes(key); } /** Returns a 429 response when over limit, otherwise null. */ export async function rateLimit(req: NextRequest): Promise { const client = getRedis(); if (!client) return null; const keyed = validApiKey(req); const limit = keyed ? KEYED_LIMIT : ANON_LIMIT; const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? req.headers.get('x-real-ip') ?? 'unknown'; const identity = keyed ? `key:${req.headers.get('x-api-key')}` : `ip:${ip}`; const windowKey = `rl:${identity}:${Math.floor(Date.now() / 1000 / WINDOW_S)}`; try { const count = await client.incr(windowKey); if (count === 1) await client.expire(windowKey, WINDOW_S + 5); if (count > limit) { const retryAfter = WINDOW_S - (Math.floor(Date.now() / 1000) % WINDOW_S); return NextResponse.json( { error: 'rate_limited', limit, window_seconds: WINDOW_S }, { status: 429, headers: { 'Retry-After': String(retryAfter) } }, ); } } catch { return null; // fail open } return null; }