anky2002 commited on
Commit
5db0abb
·
verified ·
1 Parent(s): 9d380cf

fix: rate limiter now supports semantic search burst limits + better cleanup

Browse files
Files changed (1) hide show
  1. 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 (IP or user ID).
51
- * Uses Redis with an in-memory fallback NEVER fails open.
52
  */
53
  export async function checkRateLimit(
54
  identifier: string,
55
  isAuthenticated: boolean = false,
56
  isPro: boolean = false,
57
  ): Promise<RateLimitResult> {
58
- const config = isPro ? RATE_LIMITS.pro : isAuthenticated ? RATE_LIMITS.user : RATE_LIMITS.guest
 
 
 
 
 
 
 
 
 
 
 
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 (NEVER fail open)
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. Redis unavailable, using in-memory limit. Try again in ${config.window} seconds.`,
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')