Skip to content

Commit c0ed87a

Browse files
committed
feat(backend): Add PlaygroundLimiter service with Redis backing
- Session-based limiting (50/day per device via httpOnly cookie) - IP-based fallback (100/day for shared networks) - Global circuit breaker (10k/hour for cost protection) - Fail-open design if Redis unavailable - PlaygroundLimitResult dataclass for structured responses Part of #93
1 parent 72d7b78 commit c0ed87a

1 file changed

Lines changed: 301 additions & 0 deletions

File tree

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
"""
2+
Playground Rate Limiter
3+
Redis-backed rate limiting for anonymous playground searches.
4+
5+
Design:
6+
- Layer 1: Session token (httpOnly cookie) - 50 searches/day per device
7+
- Layer 2: IP-based fallback - 100 searches/day (for shared IPs)
8+
- Layer 3: Global circuit breaker - 10,000 searches/hour (cost protection)
9+
10+
Part of #93 implementation.
11+
"""
12+
import secrets
13+
import hashlib
14+
from datetime import datetime, timezone
15+
from typing import Optional, Tuple
16+
from dataclasses import dataclass
17+
18+
from services.observability import logger
19+
from services.sentry import capture_exception
20+
21+
22+
@dataclass
23+
class PlaygroundLimitResult:
24+
"""Result of a rate limit check"""
25+
allowed: bool
26+
remaining: int
27+
limit: int
28+
resets_at: datetime
29+
reason: Optional[str] = None # Why blocked (if not allowed)
30+
session_token: Optional[str] = None # New token if created
31+
32+
def to_dict(self) -> dict:
33+
return {
34+
"allowed": self.allowed,
35+
"remaining": self.remaining,
36+
"limit": self.limit,
37+
"resets_at": self.resets_at.isoformat(),
38+
"reason": self.reason,
39+
}
40+
41+
42+
class PlaygroundLimiter:
43+
"""
44+
Redis-backed rate limiter for playground searches.
45+
46+
Usage:
47+
limiter = PlaygroundLimiter(redis_client)
48+
49+
# Check before search
50+
result = limiter.check_and_record(session_token, client_ip)
51+
if not result.allowed:
52+
raise HTTPException(429, result.reason)
53+
54+
# Set cookie if new session
55+
if result.session_token:
56+
response.set_cookie("pg_session", result.session_token, ...)
57+
"""
58+
59+
# Limits
60+
SESSION_LIMIT_PER_DAY = 50 # Per device (generous for conversion)
61+
IP_LIMIT_PER_DAY = 100 # Per IP (higher for shared networks)
62+
GLOBAL_LIMIT_PER_HOUR = 10000 # Circuit breaker (cost protection)
63+
64+
# Redis key prefixes
65+
KEY_SESSION = "playground:session:"
66+
KEY_IP = "playground:ip:"
67+
KEY_GLOBAL = "playground:global:hourly"
68+
69+
# TTLs
70+
TTL_DAY = 86400 # 24 hours
71+
TTL_HOUR = 3600 # 1 hour
72+
73+
def __init__(self, redis_client=None):
74+
self.redis = redis_client
75+
76+
def _get_midnight_utc(self) -> datetime:
77+
"""Get next midnight UTC for reset time"""
78+
now = datetime.now(timezone.utc)
79+
tomorrow = now.replace(hour=0, minute=0, second=0, microsecond=0)
80+
if tomorrow <= now:
81+
from datetime import timedelta
82+
tomorrow += timedelta(days=1)
83+
return tomorrow
84+
85+
def _hash_ip(self, ip: str) -> str:
86+
"""Hash IP for privacy"""
87+
return hashlib.sha256(ip.encode()).hexdigest()[:16]
88+
89+
def _generate_session_token(self) -> str:
90+
"""Generate secure session token"""
91+
return secrets.token_urlsafe(32)
92+
93+
def check_limit(
94+
self,
95+
session_token: Optional[str],
96+
client_ip: str
97+
) -> PlaygroundLimitResult:
98+
"""
99+
Check rate limit without recording a search.
100+
Use this for GET /playground/limits endpoint.
101+
"""
102+
return self._check_limits(session_token, client_ip, record=False)
103+
104+
def check_and_record(
105+
self,
106+
session_token: Optional[str],
107+
client_ip: str
108+
) -> PlaygroundLimitResult:
109+
"""
110+
Check rate limit AND record a search if allowed.
111+
Use this for POST /playground/search endpoint.
112+
"""
113+
return self._check_limits(session_token, client_ip, record=True)
114+
115+
def _check_limits(
116+
self,
117+
session_token: Optional[str],
118+
client_ip: str,
119+
record: bool = False
120+
) -> PlaygroundLimitResult:
121+
"""
122+
Internal method to check all rate limit layers.
123+
124+
Order of checks:
125+
1. Global circuit breaker (protects cost)
126+
2. Session-based limit (primary)
127+
3. IP-based limit (fallback)
128+
"""
129+
resets_at = self._get_midnight_utc()
130+
new_session_token = None
131+
132+
# If no Redis, fail OPEN (allow all)
133+
if not self.redis:
134+
logger.warning("Redis not available, allowing playground search")
135+
return PlaygroundLimitResult(
136+
allowed=True,
137+
remaining=self.SESSION_LIMIT_PER_DAY,
138+
limit=self.SESSION_LIMIT_PER_DAY,
139+
resets_at=resets_at,
140+
)
141+
142+
try:
143+
# Layer 1: Global circuit breaker
144+
global_allowed, global_count = self._check_global_limit(record)
145+
if not global_allowed:
146+
logger.warning("Global circuit breaker triggered", count=global_count)
147+
return PlaygroundLimitResult(
148+
allowed=False,
149+
remaining=0,
150+
limit=self.SESSION_LIMIT_PER_DAY,
151+
resets_at=resets_at,
152+
reason="Service is experiencing high demand. Please try again later.",
153+
)
154+
155+
# Layer 2: Session-based limit (primary)
156+
if session_token:
157+
session_allowed, session_remaining = self._check_session_limit(
158+
session_token, record
159+
)
160+
if session_allowed:
161+
return PlaygroundLimitResult(
162+
allowed=True,
163+
remaining=session_remaining,
164+
limit=self.SESSION_LIMIT_PER_DAY,
165+
resets_at=resets_at,
166+
)
167+
else:
168+
# Session exhausted
169+
return PlaygroundLimitResult(
170+
allowed=False,
171+
remaining=0,
172+
limit=self.SESSION_LIMIT_PER_DAY,
173+
resets_at=resets_at,
174+
reason="Daily limit reached. Sign up for unlimited searches!",
175+
)
176+
177+
# No session token - create new one and check IP
178+
new_session_token = self._generate_session_token()
179+
180+
# Layer 3: IP-based limit (for new sessions / fallback)
181+
ip_allowed, ip_remaining = self._check_ip_limit(client_ip, record)
182+
if not ip_allowed:
183+
# IP exhausted (likely abuse or shared network)
184+
return PlaygroundLimitResult(
185+
allowed=False,
186+
remaining=0,
187+
limit=self.SESSION_LIMIT_PER_DAY,
188+
resets_at=resets_at,
189+
reason="Daily limit reached. Sign up for unlimited searches!",
190+
)
191+
192+
# New session allowed
193+
if record:
194+
# Initialize session counter
195+
session_key = f"{self.KEY_SESSION}{new_session_token}"
196+
self.redis.set(session_key, "1", ex=self.TTL_DAY)
197+
198+
return PlaygroundLimitResult(
199+
allowed=True,
200+
remaining=self.SESSION_LIMIT_PER_DAY - 1 if record else self.SESSION_LIMIT_PER_DAY,
201+
limit=self.SESSION_LIMIT_PER_DAY,
202+
resets_at=resets_at,
203+
session_token=new_session_token,
204+
)
205+
206+
except Exception as e:
207+
logger.error("Playground rate limit check failed", error=str(e))
208+
capture_exception(e)
209+
# Fail OPEN - allow search but don't break UX
210+
return PlaygroundLimitResult(
211+
allowed=True,
212+
remaining=self.SESSION_LIMIT_PER_DAY,
213+
limit=self.SESSION_LIMIT_PER_DAY,
214+
resets_at=resets_at,
215+
)
216+
217+
def _check_global_limit(self, record: bool) -> Tuple[bool, int]:
218+
"""Check global circuit breaker"""
219+
try:
220+
if record:
221+
count = self.redis.incr(self.KEY_GLOBAL)
222+
if count == 1:
223+
self.redis.expire(self.KEY_GLOBAL, self.TTL_HOUR)
224+
else:
225+
count = int(self.redis.get(self.KEY_GLOBAL) or 0)
226+
227+
allowed = count <= self.GLOBAL_LIMIT_PER_HOUR
228+
return allowed, count
229+
except Exception as e:
230+
logger.error("Global limit check failed", error=str(e))
231+
return True, 0 # Fail open
232+
233+
def _check_session_limit(
234+
self,
235+
session_token: str,
236+
record: bool
237+
) -> Tuple[bool, int]:
238+
"""Check session-based limit"""
239+
try:
240+
session_key = f"{self.KEY_SESSION}{session_token}"
241+
242+
if record:
243+
count = self.redis.incr(session_key)
244+
if count == 1:
245+
self.redis.expire(session_key, self.TTL_DAY)
246+
else:
247+
count = int(self.redis.get(session_key) or 0)
248+
249+
remaining = max(0, self.SESSION_LIMIT_PER_DAY - count)
250+
allowed = count <= self.SESSION_LIMIT_PER_DAY
251+
return allowed, remaining
252+
except Exception as e:
253+
logger.error("Session limit check failed", error=str(e))
254+
return True, self.SESSION_LIMIT_PER_DAY # Fail open
255+
256+
def _check_ip_limit(self, client_ip: str, record: bool) -> Tuple[bool, int]:
257+
"""Check IP-based limit"""
258+
try:
259+
ip_hash = self._hash_ip(client_ip)
260+
ip_key = f"{self.KEY_IP}{ip_hash}"
261+
262+
if record:
263+
count = self.redis.incr(ip_key)
264+
if count == 1:
265+
self.redis.expire(ip_key, self.TTL_DAY)
266+
else:
267+
count = int(self.redis.get(ip_key) or 0)
268+
269+
remaining = max(0, self.IP_LIMIT_PER_DAY - count)
270+
allowed = count <= self.IP_LIMIT_PER_DAY
271+
return allowed, remaining
272+
except Exception as e:
273+
logger.error("IP limit check failed", error=str(e))
274+
return True, self.IP_LIMIT_PER_DAY # Fail open
275+
276+
def get_usage_stats(self) -> dict:
277+
"""Get current global usage stats (for monitoring)"""
278+
if not self.redis:
279+
return {"global_hourly": 0, "redis_available": False}
280+
281+
try:
282+
global_count = int(self.redis.get(self.KEY_GLOBAL) or 0)
283+
return {
284+
"global_hourly": global_count,
285+
"global_limit": self.GLOBAL_LIMIT_PER_HOUR,
286+
"redis_available": True,
287+
}
288+
except Exception as e:
289+
return {"error": str(e), "redis_available": False}
290+
291+
292+
# Singleton instance
293+
_playground_limiter: Optional[PlaygroundLimiter] = None
294+
295+
296+
def get_playground_limiter(redis_client=None) -> PlaygroundLimiter:
297+
"""Get or create PlaygroundLimiter instance"""
298+
global _playground_limiter
299+
if _playground_limiter is None:
300+
_playground_limiter = PlaygroundLimiter(redis_client)
301+
return _playground_limiter

0 commit comments

Comments
 (0)