Skip to content

Commit d2872bd

Browse files
committed
feat(backend): Wire PlaygroundLimiter to routes with cookie support
- Replace in-memory rate limiting with Redis-backed limiter - Add GET /playground/limits endpoint for frontend to check remaining - Add GET /playground/stats for monitoring - Set httpOnly session cookie on first request - Export redis_client from dependencies - Python 3.9 compatible type hints Part of #93
1 parent c0ed87a commit d2872bd

2 files changed

Lines changed: 109 additions & 35 deletions

File tree

backend/dependencies.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@
4242
# Repository size validation
4343
repo_validator = get_repo_validator()
4444

45+
# Redis client (for playground limiter and other services)
46+
redis_client = cache.redis if cache.redis else None
47+
4548

4649
def get_repo_or_404(repo_id: str, user_id: str) -> dict:
4750
"""

backend/routes/playground.py

Lines changed: 106 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
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
311
from 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
815
from services.input_validator import InputValidator
916
from services.observability import logger
17+
from services.playground_limiter import PlaygroundLimiter, get_playground_limiter
1018

1119
router = APIRouter(prefix="/playground", tags=["Playground"])
1220

1321
# Demo repo mapping (populated on startup)
1422
DEMO_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

2229
class 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-
6355
def _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

Comments
 (0)