open-prompt / src /lib /rate-limit.ts
anky2002's picture
fix: rate limiter now supports semantic search burst limits + better cleanup
5db0abb verified
import { redisIncr, redisExpire, redisTTL } from './redis'
// Rate limit configurations
const RATE_LIMITS = {
guest: {
max: 10,
window: 3600, // 1 hour in seconds
},
user: {
max: 50,
window: 3600,
},
pro: {
max: 500,
window: 3600,
},
}
// Prefix-specific overrides (for specialized endpoints)
const PREFIX_LIMITS: Record<string, { max: number; window: number }> = {
'search-semantic': { max: 10, window: 3600 }, // 10 semantic searches/hr
'search-semantic-burst': { max: 3, window: 60 }, // 3 per minute burst
'search': { max: 60, window: 3600 }, // 60 regular searches/hr
'prompts': { max: 30, window: 3600 }, // 30 prompt creations/hr
}
// In-memory fallback when Redis is unavailable
const memoryCounters = new Map<string, { count: number; resetAt: number }>()
let lastCleanup = Date.now()
function checkMemoryLimit(key: string, max: number, window: number): { count: number; allowed: boolean } {
const now = Date.now()
// Periodic cleanup (every 5 minutes or when map is large)
if (now - lastCleanup > 300_000 || memoryCounters.size > 5000) {
for (const [k, v] of memoryCounters.entries()) {
if (v.resetAt <= now) memoryCounters.delete(k)
}
lastCleanup = now
}
const entry = memoryCounters.get(key)
if (!entry || entry.resetAt <= now) {
memoryCounters.set(key, { count: 1, resetAt: now + window * 1000 })
return { count: 1, allowed: true }
}
entry.count += 1
return { count: entry.count, allowed: entry.count <= max }
}
interface RateLimitResult {
success: boolean
limit: number
remaining: number
reset: number
error?: string
}
/**
* Check rate limit for a given identifier.
* Supports prefix-based overrides for specialized endpoints.
*/
export async function checkRateLimit(
identifier: string,
isAuthenticated: boolean = false,
isPro: boolean = false,
): Promise<RateLimitResult> {
// Check if there's a prefix-specific limit
let config = isPro ? RATE_LIMITS.pro : isAuthenticated ? RATE_LIMITS.user : RATE_LIMITS.guest
// Extract prefix (e.g., "search-semantic:user:abc123" → "search-semantic")
const colonIndex = identifier.indexOf(':')
if (colonIndex > 0) {
const prefix = identifier.substring(0, colonIndex)
if (PREFIX_LIMITS[prefix]) {
config = PREFIX_LIMITS[prefix]
}
}
const key = `ratelimit:${identifier}`
// Try Redis first
const currentCount = await redisIncr(key)
if (currentCount === null) {
// Redis unavailable — use in-memory fallback
const { count, allowed } = checkMemoryLimit(key, config.max, config.window)
const resetAt = Date.now() + config.window * 1000
if (!allowed) {
return {
success: false,
limit: config.max,
remaining: 0,
reset: resetAt,
error: `Rate limit exceeded. Try again in ${config.window} seconds.`,
}
}
return {
success: true,
limit: config.max,
remaining: Math.max(0, config.max - count),
reset: resetAt,
}
}
// Set expiration on first request
if (currentCount === 1) {
await redisExpire(key, config.window)
}
// Check if limit exceeded
if (currentCount > config.max) {
const ttl = await redisTTL(key)
const resetTime = Date.now() + (ttl || config.window) * 1000
return {
success: false,
limit: config.max,
remaining: 0,
reset: resetTime,
error: `Rate limit exceeded. Try again in ${ttl || config.window} seconds.`,
}
}
// Get TTL for reset time
const ttl = await redisTTL(key)
const resetTime = Date.now() + (ttl || config.window) * 1000
return {
success: true,
limit: config.max,
remaining: config.max - currentCount,
reset: resetTime,
}
}
/**
* Get client identifier from request (IP address or user ID)
*/
export function getClientIdentifier(request: Request, userId?: string): string {
if (userId) {
return `user:${userId}`
}
const cfConnectingIp = request.headers.get('cf-connecting-ip')
const forwarded = request.headers.get('x-forwarded-for')
const realIp = request.headers.get('x-real-ip')
const ip = cfConnectingIp || forwarded?.split(',')[0]?.trim() || realIp || 'unknown'
return `ip:${ip}`
}
/**
* Create rate limit headers for response
*/
export function getRateLimitHeaders(result: RateLimitResult): Headers {
const headers = new Headers()
headers.set('X-RateLimit-Limit', result.limit.toString())
headers.set('X-RateLimit-Remaining', Math.max(0, result.remaining).toString())
headers.set('X-RateLimit-Reset', Math.floor(result.reset / 1000).toString())
return headers
}