1- """Playground routes - no auth required, rate limited."""
2- from fastapi import APIRouter , HTTPException , Request
1+ """
2+ Playground routes - no auth required, rate limited via Redis.
3+
4+ Rate limiting strategy (see #93):
5+ - Session token (httpOnly cookie): 50 searches/day per device
6+ - IP fallback: 100 searches/day for shared networks
7+ - Global circuit breaker: 10k searches/hour (cost protection)
8+ """
9+ import os
10+ from typing import Optional
11+ from fastapi import APIRouter , HTTPException , Request , Response
312from pydantic import BaseModel
4- from collections import defaultdict
5- import time as time_module
13+ import time
614
7- from dependencies import indexer , cache , repo_manager
15+ from dependencies import indexer , cache , repo_manager , redis_client
816from services .input_validator import InputValidator
917from services .observability import logger
18+ from services .playground_limiter import PlaygroundLimiter , get_playground_limiter
1019
1120router = APIRouter (prefix = "/playground" , tags = ["Playground" ])
1221
1322# Demo repo mapping (populated on startup)
1423DEMO_REPO_IDS = {}
1524
16- # Rate limiting config
17- PLAYGROUND_LIMIT = 10 # searches per hour
18- PLAYGROUND_WINDOW = 3600 # 1 hour
19- playground_rate_limits = defaultdict ( list )
25+ # Session cookie config
26+ SESSION_COOKIE_NAME = "pg_session"
27+ SESSION_COOKIE_MAX_AGE = 86400 # 24 hours
28+ IS_PRODUCTION = os . getenv ( "ENVIRONMENT" , "development" ). lower () == "production"
2029
2130
2231class PlaygroundSearchRequest (BaseModel ):
@@ -45,21 +54,6 @@ async def load_demo_repos():
4554 logger .warning ("Could not load demo repos" , error = str (e ))
4655
4756
48- def _check_rate_limit (ip : str ) -> tuple [bool , int ]:
49- """Check if IP is within rate limit."""
50- now = time_module .time ()
51- playground_rate_limits [ip ] = [
52- t for t in playground_rate_limits [ip ] if now - t < PLAYGROUND_WINDOW
53- ]
54- remaining = PLAYGROUND_LIMIT - len (playground_rate_limits [ip ])
55- return (remaining > 0 , max (0 , remaining ))
56-
57-
58- def _record_search (ip : str ):
59- """Record a search for rate limiting."""
60- playground_rate_limits [ip ].append (time_module .time ())
61-
62-
6357def _get_client_ip (req : Request ) -> str :
6458 """Extract client IP from request."""
6559 client_ip = req .client .host if req .client else "unknown"
@@ -69,19 +63,82 @@ def _get_client_ip(req: Request) -> str:
6963 return client_ip
7064
7165
66+ def _get_session_token (req : Request ) -> Optional [str ]:
67+ """Get session token from cookie."""
68+ return req .cookies .get (SESSION_COOKIE_NAME )
69+
70+
71+ def _set_session_cookie (response : Response , token : str ):
72+ """Set httpOnly session cookie."""
73+ response .set_cookie (
74+ key = SESSION_COOKIE_NAME ,
75+ value = token ,
76+ max_age = SESSION_COOKIE_MAX_AGE ,
77+ httponly = True , # Can't be accessed by JavaScript
78+ samesite = "lax" , # CSRF protection
79+ secure = IS_PRODUCTION , # HTTPS only in production
80+ )
81+
82+
83+ def _get_limiter () -> PlaygroundLimiter :
84+ """Get the playground limiter instance."""
85+ return get_playground_limiter (redis_client )
86+
87+
88+ @router .get ("/limits" )
89+ async def get_playground_limits (req : Request ):
90+ """
91+ Get current rate limit status for this user.
92+
93+ Frontend should call this on page load to show accurate remaining count.
94+ """
95+ session_token = _get_session_token (req )
96+ client_ip = _get_client_ip (req )
97+
98+ limiter = _get_limiter ()
99+ result = limiter .check_limit (session_token , client_ip )
100+
101+ return {
102+ "remaining" : result .remaining ,
103+ "limit" : result .limit ,
104+ "resets_at" : result .resets_at .isoformat (),
105+ "tier" : "anonymous" ,
106+ }
107+
108+
72109@router .post ("/search" )
73- async def playground_search (request : PlaygroundSearchRequest , req : Request ):
74- """Public playground search - rate limited by IP."""
110+ async def playground_search (
111+ request : PlaygroundSearchRequest ,
112+ req : Request ,
113+ response : Response
114+ ):
115+ """
116+ Public playground search - rate limited by session/IP.
117+
118+ Sets httpOnly cookie on first request to track device.
119+ """
120+ session_token = _get_session_token (req )
75121 client_ip = _get_client_ip (req )
76122
77- # Rate limit check
78- allowed , remaining = _check_rate_limit (client_ip )
79- if not allowed :
123+ # Rate limit check AND record
124+ limiter = _get_limiter ()
125+ limit_result = limiter .check_and_record (session_token , client_ip )
126+
127+ if not limit_result .allowed :
80128 raise HTTPException (
81129 status_code = 429 ,
82- detail = "Rate limit exceeded. Sign up for unlimited searches!"
130+ detail = {
131+ "message" : limit_result .reason ,
132+ "remaining" : 0 ,
133+ "limit" : limit_result .limit ,
134+ "resets_at" : limit_result .resets_at .isoformat (),
135+ }
83136 )
84137
138+ # Set session cookie if new token was created
139+ if limit_result .session_token :
140+ _set_session_cookie (response , limit_result .session_token )
141+
85142 # Validate query
86143 valid_query , query_error = InputValidator .validate_search_query (request .query )
87144 if not valid_query :
@@ -100,7 +157,6 @@ async def playground_search(request: PlaygroundSearchRequest, req: Request):
100157 detail = f"Demo repo '{ request .demo_repo } ' not available"
101158 )
102159
103- import time
104160 start_time = time .time ()
105161
106162 try :
@@ -113,7 +169,8 @@ async def playground_search(request: PlaygroundSearchRequest, req: Request):
113169 "results" : cached_results ,
114170 "count" : len (cached_results ),
115171 "cached" : True ,
116- "remaining_searches" : remaining
172+ "remaining_searches" : limit_result .remaining ,
173+ "limit" : limit_result .limit ,
117174 }
118175
119176 # Search
@@ -125,17 +182,23 @@ async def playground_search(request: PlaygroundSearchRequest, req: Request):
125182 use_reranking = True
126183 )
127184
128- # Cache and record
185+ # Cache results
129186 cache .set_search_results (sanitized_query , repo_id , results , ttl = 3600 )
130- _record_search (client_ip )
187+
188+ search_time = int ((time .time () - start_time ) * 1000 )
131189
132190 return {
133191 "results" : results ,
134192 "count" : len (results ),
135193 "cached" : False ,
136- "remaining_searches" : remaining - 1
194+ "remaining_searches" : limit_result .remaining ,
195+ "limit" : limit_result .limit ,
196+ "search_time_ms" : search_time ,
137197 }
198+ except HTTPException :
199+ raise
138200 except Exception as e :
201+ logger .error ("Playground search failed" , error = str (e ))
139202 raise HTTPException (status_code = 500 , detail = str (e ))
140203
141204
@@ -149,3 +212,13 @@ async def list_playground_repos():
149212 {"id" : "express" , "name" : "Express" , "description" : "Node.js framework" , "available" : "express" in DEMO_REPO_IDS },
150213 ]
151214 }
215+
216+
217+ @router .get ("/stats" )
218+ async def get_playground_stats ():
219+ """
220+ Get playground usage stats (for monitoring/debugging).
221+ """
222+ limiter = _get_limiter ()
223+ stats = limiter .get_usage_stats ()
224+ return stats
0 commit comments