import { isProKey } from "../services/keys.js"; import logger from "../services/logger.js"; // Per-key rate limits (requests per minute) const FREE_RATE_LIMIT = 10; const PRO_RATE_LIMIT = 30; const RATE_WINDOW_MS = 60_000; // 1 minute // Concurrency limits const MAX_CONCURRENT_PDFS = 3; const MAX_QUEUE_SIZE = 10; // Per-key queue fairness (Audit #15) const MAX_QUEUED_PER_KEY = 3; const rateLimitStore = new Map(); let activePdfCount = 0; const pdfQueue = []; function cleanupExpiredEntries() { const now = Date.now(); for (const [key, entry] of rateLimitStore.entries()) { if (now >= entry.resetTime) { rateLimitStore.delete(key); } } } function getRateLimit(apiKey) { return isProKey(apiKey) ? PRO_RATE_LIMIT : FREE_RATE_LIMIT; } function checkRateLimit(apiKey) { cleanupExpiredEntries(); const now = Date.now(); const limit = getRateLimit(apiKey); const entry = rateLimitStore.get(apiKey); if (!entry || now >= entry.resetTime) { const resetTime = now + RATE_WINDOW_MS; rateLimitStore.set(apiKey, { count: 1, resetTime }); return { allowed: true, limit, remaining: limit - 1, resetTime }; } if (entry.count >= limit) { return { allowed: false, limit, remaining: 0, resetTime: entry.resetTime }; } entry.count++; return { allowed: true, limit, remaining: limit - entry.count, resetTime: entry.resetTime }; } function getQueuedCountForKey(apiKey) { return pdfQueue.filter(w => w.apiKey === apiKey).length; } async function acquireConcurrencySlot(apiKey) { if (activePdfCount < MAX_CONCURRENT_PDFS) { activePdfCount++; return; } if (pdfQueue.length >= MAX_QUEUE_SIZE) { throw new Error("QUEUE_FULL"); } // Audit #15: Per-key fairness — reject if this key already has too many queued if (getQueuedCountForKey(apiKey) >= MAX_QUEUED_PER_KEY) { logger.warn({ apiKey: apiKey.slice(0, 8) + "..." }, "Per-key queue limit reached"); throw new Error("QUEUE_FULL"); } return new Promise((resolve, reject) => { pdfQueue.push({ resolve, reject, apiKey }); }); } function releaseConcurrencySlot() { activePdfCount--; const waiter = pdfQueue.shift(); if (waiter) { activePdfCount++; waiter.resolve(); } } export function pdfRateLimitMiddleware(req, res, next) { const keyInfo = req.apiKeyInfo; const apiKey = keyInfo?.key || "unknown"; // Check rate limit first const rateLimitResult = checkRateLimit(apiKey); // Set rate limit headers on ALL responses res.set('X-RateLimit-Limit', String(rateLimitResult.limit)); res.set('X-RateLimit-Remaining', String(rateLimitResult.remaining)); res.set('X-RateLimit-Reset', String(Math.ceil(rateLimitResult.resetTime / 1000))); if (!rateLimitResult.allowed) { const tier = isProKey(apiKey) ? "pro" : "free"; const retryAfterSeconds = Math.ceil((rateLimitResult.resetTime - Date.now()) / 1000); res.set('Retry-After', String(retryAfterSeconds)); res.status(429).json({ error: `Rate limit exceeded: ${rateLimitResult.limit} PDFs/min allowed for ${tier} tier. Retry after ${retryAfterSeconds}s.` }); return; } // Add concurrency control to the request (pass apiKey for fairness) req.acquirePdfSlot = () => acquireConcurrencySlot(apiKey); req.releasePdfSlot = releaseConcurrencySlot; next(); } export function getConcurrencyStats() { return { activePdfCount, queueSize: pdfQueue.length, maxConcurrent: MAX_CONCURRENT_PDFS, maxQueue: MAX_QUEUE_SIZE }; } // Proactive cleanup every 60s setInterval(cleanupExpiredEntries, 60_000).unref();