From 978c3dc2d441ca492882f6e560a9a8f6c0d3be97 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Mon, 23 Feb 2026 07:04:30 +0000 Subject: [PATCH] Add standard rate limit headers to PDF conversion endpoints - Modified checkRateLimit to return RateLimitResult object with limit, remaining, and resetTime - Added X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset headers to ALL responses - Added Retry-After header to 429 responses - Headers now provide developers visibility into their quota usage --- src/middleware/pdfRateLimit.ts | 40 +++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/middleware/pdfRateLimit.ts b/src/middleware/pdfRateLimit.ts index e34d64a..0d64d89 100644 --- a/src/middleware/pdfRateLimit.ts +++ b/src/middleware/pdfRateLimit.ts @@ -43,7 +43,7 @@ function getRateLimit(apiKey: string): number { return isProKey(apiKey) ? PRO_RATE_LIMIT : FREE_RATE_LIMIT; } -function checkRateLimit(apiKey: string): boolean { +function checkRateLimit(apiKey: string): RateLimitResult { cleanupExpiredEntries(); const now = Date.now(); @@ -51,19 +51,35 @@ function checkRateLimit(apiKey: string): boolean { const entry = rateLimitStore.get(apiKey); if (!entry || now >= entry.resetTime) { + const resetTime = now + RATE_WINDOW_MS; rateLimitStore.set(apiKey, { count: 1, - resetTime: now + RATE_WINDOW_MS + resetTime }); - return true; + return { + allowed: true, + limit, + remaining: limit - 1, + resetTime + }; } if (entry.count >= limit) { - return false; + return { + allowed: false, + limit, + remaining: 0, + resetTime: entry.resetTime + }; } entry.count++; - return true; + return { + allowed: true, + limit, + remaining: limit - entry.count, + resetTime: entry.resetTime + }; } function getQueuedCountForKey(apiKey: string): number { @@ -106,10 +122,18 @@ export function pdfRateLimitMiddleware(req: Request & { apiKeyInfo?: any }, res: const apiKey = keyInfo?.key || "unknown"; // Check rate limit first - if (!checkRateLimit(apiKey)) { - const limit = getRateLimit(apiKey); + 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"; - res.status(429).json({ error: `Rate limit exceeded: ${limit} PDFs/min allowed for ${tier} tier. Retry after 60s.` }); + 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; }