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+ from typing import Optional
10+ from fastapi import APIRouter , HTTPException , Request , Response
311from pydantic import BaseModel
4- from collections import defaultdict
5- import time as time_module
12+ import time
613
7- from dependencies import indexer , cache , repo_manager
14+ from dependencies import indexer , cache , repo_manager , redis_client
815from services .input_validator import InputValidator
916from services .observability import logger
17+ from services .playground_limiter import PlaygroundLimiter , get_playground_limiter
1018
1119router = APIRouter (prefix = "/playground" , tags = ["Playground" ])
1220
1321# Demo repo mapping (populated on startup)
1422DEMO_REPO_IDS = {}
1523
16- # Rate limiting config
17- PLAYGROUND_LIMIT = 10 # searches per hour
18- PLAYGROUND_WINDOW = 3600 # 1 hour
19- playground_rate_limits = defaultdict (list )
24+ # Session cookie config
25+ SESSION_COOKIE_NAME = "pg_session"
26+ SESSION_COOKIE_MAX_AGE = 86400 # 24 hours
2027
2128
2229class PlaygroundSearchRequest (BaseModel ):
@@ -45,21 +52,6 @@ async def load_demo_repos():
4552 logger .warning ("Could not load demo repos" , error = str (e ))
4653
4754
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-
6355def _get_client_ip (req : Request ) -> str :
6456 """Extract client IP from request."""
6557 client_ip = req .client .host if req .client else "unknown"
@@ -69,19 +61,82 @@ def _get_client_ip(req: Request) -> str:
6961 return client_ip
7062
7163
64+ def _get_session_token (req : Request ) -> Optional [str ]:
65+ """Get session token from cookie."""
66+ return req .cookies .get (SESSION_COOKIE_NAME )
67+
68+
69+ def _set_session_cookie (response : Response , token : str ):
70+ """Set httpOnly session cookie."""
71+ response .set_cookie (
72+ key = SESSION_COOKIE_NAME ,
73+ value = token ,
74+ max_age = SESSION_COOKIE_MAX_AGE ,
75+ httponly = True , # Can't be accessed by JavaScript
76+ samesite = "lax" , # CSRF protection
77+ secure = False , # Set True in production with HTTPS
78+ )
79+
80+
81+ def _get_limiter () -> PlaygroundLimiter :
82+ """Get the playground limiter instance."""
83+ return get_playground_limiter (redis_client )
84+
85+
86+ @router .get ("/limits" )
87+ async def get_playground_limits (req : Request ):
88+ """
89+ Get current rate limit status for this user.
90+
91+ Frontend should call this on page load to show accurate remaining count.
92+ """
93+ session_token = _get_session_token (req )
94+ client_ip = _get_client_ip (req )
95+
96+ limiter = _get_limiter ()
97+ result = limiter .check_limit (session_token , client_ip )
98+
99+ return {
100+ "remaining" : result .remaining ,
101+ "limit" : result .limit ,
102+ "resets_at" : result .resets_at .isoformat (),
103+ "tier" : "anonymous" ,
104+ }
105+
106+
72107@router .post ("/search" )
73- async def playground_search (request : PlaygroundSearchRequest , req : Request ):
74- """Public playground search - rate limited by IP."""
108+ async def playground_search (
109+ request : PlaygroundSearchRequest ,
110+ req : Request ,
111+ response : Response
112+ ):
113+ """
114+ Public playground search - rate limited by session/IP.
115+
116+ Sets httpOnly cookie on first request to track device.
117+ """
118+ session_token = _get_session_token (req )
75119 client_ip = _get_client_ip (req )
76120
77- # Rate limit check
78- allowed , remaining = _check_rate_limit (client_ip )
79- if not allowed :
121+ # Rate limit check AND record
122+ limiter = _get_limiter ()
123+ limit_result = limiter .check_and_record (session_token , client_ip )
124+
125+ if not limit_result .allowed :
80126 raise HTTPException (
81127 status_code = 429 ,
82- detail = "Rate limit exceeded. Sign up for unlimited searches!"
128+ detail = {
129+ "message" : limit_result .reason ,
130+ "remaining" : 0 ,
131+ "limit" : limit_result .limit ,
132+ "resets_at" : limit_result .resets_at .isoformat (),
133+ }
83134 )
84135
136+ # Set session cookie if new token was created
137+ if limit_result .session_token :
138+ _set_session_cookie (response , limit_result .session_token )
139+
85140 # Validate query
86141 valid_query , query_error = InputValidator .validate_search_query (request .query )
87142 if not valid_query :
@@ -100,7 +155,6 @@ async def playground_search(request: PlaygroundSearchRequest, req: Request):
100155 detail = f"Demo repo '{ request .demo_repo } ' not available"
101156 )
102157
103- import time
104158 start_time = time .time ()
105159
106160 try :
@@ -113,7 +167,8 @@ async def playground_search(request: PlaygroundSearchRequest, req: Request):
113167 "results" : cached_results ,
114168 "count" : len (cached_results ),
115169 "cached" : True ,
116- "remaining_searches" : remaining
170+ "remaining_searches" : limit_result .remaining ,
171+ "limit" : limit_result .limit ,
117172 }
118173
119174 # Search
@@ -125,17 +180,23 @@ async def playground_search(request: PlaygroundSearchRequest, req: Request):
125180 use_reranking = True
126181 )
127182
128- # Cache and record
183+ # Cache results
129184 cache .set_search_results (sanitized_query , repo_id , results , ttl = 3600 )
130- _record_search (client_ip )
185+
186+ search_time = int ((time .time () - start_time ) * 1000 )
131187
132188 return {
133189 "results" : results ,
134190 "count" : len (results ),
135191 "cached" : False ,
136- "remaining_searches" : remaining - 1
192+ "remaining_searches" : limit_result .remaining ,
193+ "limit" : limit_result .limit ,
194+ "search_time_ms" : search_time ,
137195 }
196+ except HTTPException :
197+ raise
138198 except Exception as e :
199+ logger .error ("Playground search failed" , error = str (e ))
139200 raise HTTPException (status_code = 500 , detail = str (e ))
140201
141202
@@ -149,3 +210,13 @@ async def list_playground_repos():
149210 {"id" : "express" , "name" : "Express" , "description" : "Node.js framework" , "available" : "express" in DEMO_REPO_IDS },
150211 ]
151212 }
213+
214+
215+ @router .get ("/stats" )
216+ async def get_playground_stats ():
217+ """
218+ Get playground usage stats (for monitoring/debugging).
219+ """
220+ limiter = _get_limiter ()
221+ stats = limiter .get_usage_stats ()
222+ return stats
0 commit comments