Spaces:
Configuration error
Configuration error
fix: rate limiter now supports semantic search burst limits + better cleanup
Browse files- src/lib/rate-limit.ts +34 -12
src/lib/rate-limit.ts
CHANGED
|
@@ -16,21 +16,33 @@ const RATE_LIMITS = {
|
|
| 16 |
},
|
| 17 |
}
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
// In-memory fallback when Redis is unavailable
|
| 20 |
const memoryCounters = new Map<string, { count: number; resetAt: number }>()
|
|
|
|
| 21 |
|
| 22 |
function checkMemoryLimit(key: string, max: number, window: number): { count: number; allowed: boolean } {
|
| 23 |
const now = Date.now()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
const entry = memoryCounters.get(key)
|
| 25 |
|
| 26 |
if (!entry || entry.resetAt <= now) {
|
| 27 |
memoryCounters.set(key, { count: 1, resetAt: now + window * 1000 })
|
| 28 |
-
// Clean up old entries periodically
|
| 29 |
-
if (memoryCounters.size > 10000) {
|
| 30 |
-
for (const [k, v] of memoryCounters.entries()) {
|
| 31 |
-
if (v.resetAt <= now) memoryCounters.delete(k)
|
| 32 |
-
}
|
| 33 |
-
}
|
| 34 |
return { count: 1, allowed: true }
|
| 35 |
}
|
| 36 |
|
|
@@ -47,22 +59,33 @@ interface RateLimitResult {
|
|
| 47 |
}
|
| 48 |
|
| 49 |
/**
|
| 50 |
-
* Check rate limit for a given identifier
|
| 51 |
-
*
|
| 52 |
*/
|
| 53 |
export async function checkRateLimit(
|
| 54 |
identifier: string,
|
| 55 |
isAuthenticated: boolean = false,
|
| 56 |
isPro: boolean = false,
|
| 57 |
): Promise<RateLimitResult> {
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
const key = `ratelimit:${identifier}`
|
| 60 |
|
| 61 |
// Try Redis first
|
| 62 |
const currentCount = await redisIncr(key)
|
| 63 |
|
| 64 |
if (currentCount === null) {
|
| 65 |
-
// Redis unavailable — use in-memory fallback
|
| 66 |
const { count, allowed } = checkMemoryLimit(key, config.max, config.window)
|
| 67 |
const resetAt = Date.now() + config.window * 1000
|
| 68 |
|
|
@@ -72,7 +95,7 @@ export async function checkRateLimit(
|
|
| 72 |
limit: config.max,
|
| 73 |
remaining: 0,
|
| 74 |
reset: resetAt,
|
| 75 |
-
error: `Rate limit exceeded.
|
| 76 |
}
|
| 77 |
}
|
| 78 |
|
|
@@ -123,7 +146,6 @@ export function getClientIdentifier(request: Request, userId?: string): string {
|
|
| 123 |
return `user:${userId}`
|
| 124 |
}
|
| 125 |
|
| 126 |
-
// Get IP from various headers (for proxies/load balancers)
|
| 127 |
const cfConnectingIp = request.headers.get('cf-connecting-ip')
|
| 128 |
const forwarded = request.headers.get('x-forwarded-for')
|
| 129 |
const realIp = request.headers.get('x-real-ip')
|
|
|
|
| 16 |
},
|
| 17 |
}
|
| 18 |
|
| 19 |
+
// Prefix-specific overrides (for specialized endpoints)
|
| 20 |
+
const PREFIX_LIMITS: Record<string, { max: number; window: number }> = {
|
| 21 |
+
'search-semantic': { max: 10, window: 3600 }, // 10 semantic searches/hr
|
| 22 |
+
'search-semantic-burst': { max: 3, window: 60 }, // 3 per minute burst
|
| 23 |
+
'search': { max: 60, window: 3600 }, // 60 regular searches/hr
|
| 24 |
+
'prompts': { max: 30, window: 3600 }, // 30 prompt creations/hr
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
// In-memory fallback when Redis is unavailable
|
| 28 |
const memoryCounters = new Map<string, { count: number; resetAt: number }>()
|
| 29 |
+
let lastCleanup = Date.now()
|
| 30 |
|
| 31 |
function checkMemoryLimit(key: string, max: number, window: number): { count: number; allowed: boolean } {
|
| 32 |
const now = Date.now()
|
| 33 |
+
|
| 34 |
+
// Periodic cleanup (every 5 minutes or when map is large)
|
| 35 |
+
if (now - lastCleanup > 300_000 || memoryCounters.size > 5000) {
|
| 36 |
+
for (const [k, v] of memoryCounters.entries()) {
|
| 37 |
+
if (v.resetAt <= now) memoryCounters.delete(k)
|
| 38 |
+
}
|
| 39 |
+
lastCleanup = now
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
const entry = memoryCounters.get(key)
|
| 43 |
|
| 44 |
if (!entry || entry.resetAt <= now) {
|
| 45 |
memoryCounters.set(key, { count: 1, resetAt: now + window * 1000 })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
return { count: 1, allowed: true }
|
| 47 |
}
|
| 48 |
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
/**
|
| 62 |
+
* Check rate limit for a given identifier.
|
| 63 |
+
* Supports prefix-based overrides for specialized endpoints.
|
| 64 |
*/
|
| 65 |
export async function checkRateLimit(
|
| 66 |
identifier: string,
|
| 67 |
isAuthenticated: boolean = false,
|
| 68 |
isPro: boolean = false,
|
| 69 |
): Promise<RateLimitResult> {
|
| 70 |
+
// Check if there's a prefix-specific limit
|
| 71 |
+
let config = isPro ? RATE_LIMITS.pro : isAuthenticated ? RATE_LIMITS.user : RATE_LIMITS.guest
|
| 72 |
+
|
| 73 |
+
// Extract prefix (e.g., "search-semantic:user:abc123" → "search-semantic")
|
| 74 |
+
const colonIndex = identifier.indexOf(':')
|
| 75 |
+
if (colonIndex > 0) {
|
| 76 |
+
const prefix = identifier.substring(0, colonIndex)
|
| 77 |
+
if (PREFIX_LIMITS[prefix]) {
|
| 78 |
+
config = PREFIX_LIMITS[prefix]
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
const key = `ratelimit:${identifier}`
|
| 83 |
|
| 84 |
// Try Redis first
|
| 85 |
const currentCount = await redisIncr(key)
|
| 86 |
|
| 87 |
if (currentCount === null) {
|
| 88 |
+
// Redis unavailable — use in-memory fallback
|
| 89 |
const { count, allowed } = checkMemoryLimit(key, config.max, config.window)
|
| 90 |
const resetAt = Date.now() + config.window * 1000
|
| 91 |
|
|
|
|
| 95 |
limit: config.max,
|
| 96 |
remaining: 0,
|
| 97 |
reset: resetAt,
|
| 98 |
+
error: `Rate limit exceeded. Try again in ${config.window} seconds.`,
|
| 99 |
}
|
| 100 |
}
|
| 101 |
|
|
|
|
| 146 |
return `user:${userId}`
|
| 147 |
}
|
| 148 |
|
|
|
|
| 149 |
const cfConnectingIp = request.headers.get('cf-connecting-ip')
|
| 150 |
const forwarded = request.headers.get('x-forwarded-for')
|
| 151 |
const realIp = request.headers.get('x-real-ip')
|