Spaces:
Sleeping
Sleeping
| /** | |
| * Rate Limiting Middleware | |
| * | |
| * In-memory rate limiter with three tiers: | |
| * 1. Total lifetime cap per key (default: 5) β hard cutoff for research preview | |
| * 2. Per-minute sliding window (default: 60) | |
| * 3. Per-day sliding window (default: 1000) | |
| * | |
| * Set RATE_LIMIT_TOTAL=0 to disable the lifetime cap. | |
| * | |
| * Designed for a research preview β not a production-grade limiter. | |
| * For production, use Redis-backed rate limiting. | |
| */ | |
| import type { Request, Response, NextFunction } from 'express' | |
| interface RateBucket { | |
| totalRequests: number // lifetime count (never resets) | |
| minuteRequests: number[] // timestamps of requests in current window | |
| dayRequests: number[] | |
| } | |
| const TOTAL_LIMIT = parseInt(process.env.RATE_LIMIT_TOTAL || '5', 10) | |
| const MINUTE_LIMIT = parseInt(process.env.RATE_LIMIT_PER_MINUTE || '60', 10) | |
| const DAY_LIMIT = parseInt(process.env.RATE_LIMIT_PER_DAY || '1000', 10) | |
| const MINUTE_MS = 60 * 1000 | |
| const DAY_MS = 24 * 60 * 60 * 1000 | |
| const buckets = new Map<string, RateBucket>() | |
| // Clean up stale sliding-window entries every 10 minutes | |
| // (totalRequests never gets cleaned β it's a lifetime counter) | |
| setInterval(() => { | |
| const now = Date.now() | |
| for (const [key, bucket] of buckets) { | |
| bucket.minuteRequests = bucket.minuteRequests.filter(t => now - t < MINUTE_MS) | |
| bucket.dayRequests = bucket.dayRequests.filter(t => now - t < DAY_MS) | |
| // Don't delete buckets that still have a total count β they need to stay blocked | |
| } | |
| }, 10 * 60 * 1000) | |
| export function rateLimit(req: Request, res: Response, next: NextFunction): void { | |
| const keyId = (req as any).apiKeyId || 'unknown' | |
| const now = Date.now() | |
| if (!buckets.has(keyId)) { | |
| buckets.set(keyId, { totalRequests: 0, minuteRequests: [], dayRequests: [] }) | |
| } | |
| const bucket = buckets.get(keyId)! | |
| // ββ Check lifetime total cap (hard cutoff) ββββββββββββββββββββββ | |
| if (TOTAL_LIMIT > 0 && bucket.totalRequests >= TOTAL_LIMIT) { | |
| res.status(429).json({ | |
| error: 'Request limit reached for this API key', | |
| limit: TOTAL_LIMIT, | |
| used: bucket.totalRequests, | |
| remaining: 0, | |
| note: 'This is a research preview with a limited number of requests per key. Contact the API host for more access.', | |
| }) | |
| return | |
| } | |
| // Prune expired sliding-window timestamps | |
| bucket.minuteRequests = bucket.minuteRequests.filter(t => now - t < MINUTE_MS) | |
| bucket.dayRequests = bucket.dayRequests.filter(t => now - t < DAY_MS) | |
| // ββ Check per-minute limit ββββββββββββββββββββββββββββββββββββββ | |
| if (bucket.minuteRequests.length >= MINUTE_LIMIT) { | |
| const retryAfter = Math.ceil((bucket.minuteRequests[0] + MINUTE_MS - now) / 1000) | |
| res.status(429).json({ | |
| error: 'Rate limit exceeded (per-minute)', | |
| limit: MINUTE_LIMIT, | |
| window: '1 minute', | |
| retry_after_seconds: retryAfter, | |
| }) | |
| return | |
| } | |
| // ββ Check per-day limit βββββββββββββββββββββββββββββββββββββββββ | |
| if (bucket.dayRequests.length >= DAY_LIMIT) { | |
| const retryAfter = Math.ceil((bucket.dayRequests[0] + DAY_MS - now) / 1000) | |
| res.status(429).json({ | |
| error: 'Rate limit exceeded (daily)', | |
| limit: DAY_LIMIT, | |
| window: '24 hours', | |
| retry_after_seconds: retryAfter, | |
| }) | |
| return | |
| } | |
| // Record the request | |
| bucket.totalRequests++ | |
| bucket.minuteRequests.push(now) | |
| bucket.dayRequests.push(now) | |
| // Set rate limit headers | |
| const totalRemaining = TOTAL_LIMIT > 0 ? TOTAL_LIMIT - bucket.totalRequests : Infinity | |
| res.setHeader('X-RateLimit-Limit-Total', TOTAL_LIMIT > 0 ? TOTAL_LIMIT : 'unlimited') | |
| res.setHeader('X-RateLimit-Remaining-Total', TOTAL_LIMIT > 0 ? totalRemaining : 'unlimited') | |
| res.setHeader('X-RateLimit-Limit-Minute', MINUTE_LIMIT) | |
| res.setHeader('X-RateLimit-Remaining-Minute', MINUTE_LIMIT - bucket.minuteRequests.length) | |
| res.setHeader('X-RateLimit-Limit-Day', DAY_LIMIT) | |
| res.setHeader('X-RateLimit-Remaining-Day', DAY_LIMIT - bucket.dayRequests.length) | |
| next() | |
| } | |