Skip to content

Commit 622a852

Browse files
authored
Merge pull request #102 from DevanshuNEU/feat/issue-93-playground-rate-limiting
feat: Redis-backed playground rate limiting with session cookies (#93)
2 parents 72d7b78 + 13b59d3 commit 622a852

5 files changed

Lines changed: 490 additions & 65 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: 108 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,31 @@
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
312
from 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
816
from services.input_validator import InputValidator
917
from services.observability import logger
18+
from services.playground_limiter import PlaygroundLimiter, get_playground_limiter
1019

1120
router = APIRouter(prefix="/playground", tags=["Playground"])
1221

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

2231
class 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-
6357
def _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

Comments
 (0)