Skip to content

Commit 7fddd68

Browse files
CopilotMte90
andcommitted
Add rate limiting for API endpoints
Co-authored-by: Mte90 <403283+Mte90@users.noreply.github.com>
1 parent ec368ff commit 7fddd68

File tree

2 files changed

+97
-2
lines changed

2 files changed

+97
-2
lines changed

main.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
QueryRequest
2222
)
2323
from logger import get_logger
24+
from rate_limiter import query_limiter, indexing_limiter, general_limiter
2425

2526
logger = get_logger(__name__)
2627

@@ -29,6 +30,14 @@
2930
# Controls how many characters of each snippet and total context we send to coding model
3031
TOTAL_CONTEXT_LIMIT = 4000
3132

33+
34+
def _get_client_ip(request: Request) -> str:
35+
"""Get client IP address from request."""
36+
forwarded = request.headers.get("X-Forwarded-For")
37+
if forwarded:
38+
return forwarded.split(",")[0].strip()
39+
return request.client.host if request.client else "unknown"
40+
3241
@asynccontextmanager
3342
async def lifespan(app: FastAPI):
3443
# Project registry is auto-initialized when needed via create_project
@@ -113,8 +122,18 @@ def api_delete_project(project_id: str):
113122

114123

115124
@app.post("/api/projects/index")
116-
def api_index_project(request: IndexProjectRequest, background_tasks: BackgroundTasks):
125+
def api_index_project(http_request: Request, request: IndexProjectRequest, background_tasks: BackgroundTasks):
117126
"""Index/re-index a project in the background."""
127+
# Rate limiting for indexing operations (more strict)
128+
client_ip = _get_client_ip(http_request)
129+
allowed, retry_after = indexing_limiter.is_allowed(client_ip)
130+
if not allowed:
131+
return JSONResponse(
132+
{"error": "Rate limit exceeded for indexing", "retry_after": retry_after},
133+
status_code=429,
134+
headers={"Retry-After": str(retry_after)}
135+
)
136+
118137
try:
119138
project = get_project_by_id(request.project_id)
120139
if not project:
@@ -149,8 +168,18 @@ def index_callback():
149168

150169

151170
@app.post("/api/query")
152-
def api_query(request: QueryRequest):
171+
def api_query(http_request: Request, request: QueryRequest):
153172
"""Query a project using semantic search (PyCharm-compatible)."""
173+
# Rate limiting
174+
client_ip = _get_client_ip(http_request)
175+
allowed, retry_after = query_limiter.is_allowed(client_ip)
176+
if not allowed:
177+
return JSONResponse(
178+
{"error": "Rate limit exceeded", "retry_after": retry_after},
179+
status_code=429,
180+
headers={"Retry-After": str(retry_after)}
181+
)
182+
154183
try:
155184
project = get_project_by_id(request.project_id)
156185
if not project:

rate_limiter.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""
2+
Simple rate limiter middleware for FastAPI endpoints.
3+
"""
4+
import time
5+
import threading
6+
from typing import Dict, Tuple
7+
from collections import defaultdict
8+
9+
10+
class RateLimiter:
11+
"""
12+
Token bucket rate limiter for API endpoints.
13+
Thread-safe implementation.
14+
"""
15+
16+
def __init__(self, calls: int = 100, window: int = 60):
17+
"""
18+
Initialize rate limiter.
19+
20+
Args:
21+
calls: Maximum number of calls allowed
22+
window: Time window in seconds
23+
"""
24+
self.calls = calls
25+
self.window = window
26+
self._storage: Dict[str, list] = defaultdict(list)
27+
self._lock = threading.Lock()
28+
29+
def is_allowed(self, key: str) -> Tuple[bool, int]:
30+
"""
31+
Check if request is allowed under rate limit.
32+
33+
Args:
34+
key: Identifier for rate limit (e.g., IP address)
35+
36+
Returns:
37+
Tuple of (allowed: bool, retry_after: int seconds)
38+
"""
39+
with self._lock:
40+
now = time.time()
41+
timestamps = self._storage[key]
42+
43+
# Remove timestamps outside the window
44+
timestamps[:] = [ts for ts in timestamps if ts > now - self.window]
45+
46+
if len(timestamps) >= self.calls:
47+
# Rate limit exceeded
48+
retry_after = int(timestamps[0] + self.window - now) + 1
49+
return False, retry_after
50+
51+
# Allow request and record timestamp
52+
timestamps.append(now)
53+
return True, 0
54+
55+
def reset(self, key: str):
56+
"""Reset rate limit for a key."""
57+
with self._lock:
58+
if key in self._storage:
59+
del self._storage[key]
60+
61+
62+
# Global rate limiters for different endpoint types
63+
# More permissive for queries, stricter for indexing operations
64+
query_limiter = RateLimiter(calls=100, window=60) # 100 queries per minute
65+
indexing_limiter = RateLimiter(calls=10, window=60) # 10 indexing operations per minute
66+
general_limiter = RateLimiter(calls=200, window=60) # 200 general requests per minute

0 commit comments

Comments
 (0)