Skip to content

Commit fac3faa

Browse files
authored
Add rate limit response headers (imDarshanGK#151)
* Add rate limit response headers * Fix workflow YAML syntax * Fix remaining workflow YAML syntax * Restore unrelated files from upstream main
1 parent e81c2a9 commit fac3faa

2 files changed

Lines changed: 69 additions & 16 deletions

File tree

backend/app/main.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
FastAPI application with advanced middleware, rate limiting, and full analysis engine.
44
"""
55

6-
from fastapi import FastAPI, Request, HTTPException
6+
from fastapi import FastAPI, Request
77
from fastapi.middleware.cors import CORSMiddleware
88
from fastapi.middleware.gzip import GZipMiddleware
99
from fastapi.responses import JSONResponse
@@ -19,19 +19,28 @@
1919

2020
# ── Rate limiter (in-memory, per IP) ──────────────────────────────────────────
2121
RATE_LIMIT = int(os.getenv("RATE_LIMIT_PER_MINUTE", "30"))
22+
RATE_LIMIT_WINDOW_SECONDS = 60
2223
_request_counts: dict[str, list[float]] = defaultdict(list)
2324

2425

25-
def check_rate_limit(ip: str) -> None:
26+
def check_rate_limit(ip: str) -> int:
27+
"""Record a request and return the remaining requests in the current window."""
2628
now = time.time()
27-
window = 60.0
28-
_request_counts[ip] = [t for t in _request_counts[ip] if now - t < window]
29+
_request_counts[ip] = [
30+
t for t in _request_counts[ip] if now - t < RATE_LIMIT_WINDOW_SECONDS
31+
]
2932
if len(_request_counts[ip]) >= RATE_LIMIT:
30-
raise HTTPException(
31-
status_code=429,
32-
detail=f"Rate limit exceeded. Max {RATE_LIMIT} requests/minute.",
33-
)
33+
return -1
3434
_request_counts[ip].append(now)
35+
return RATE_LIMIT - len(_request_counts[ip])
36+
37+
38+
def rate_limit_headers(remaining: int) -> dict[str, str]:
39+
"""Build rate limit headers for API responses."""
40+
return {
41+
"X-RateLimit-Limit": str(RATE_LIMIT),
42+
"X-RateLimit-Remaining": str(max(remaining, 0)),
43+
}
3544

3645

3746
# ── Lifespan ──────────────────────────────────────────────────────────────────
@@ -67,13 +76,28 @@ async def lifespan(app: FastAPI):
6776
async def add_process_time_header(request: Request, call_next):
6877
start = time.perf_counter()
6978
ip = request.client.host if request.client else "unknown"
79+
remaining = RATE_LIMIT
7080

7181
# Apply rate limiting to analysis endpoints only
72-
if request.url.path in ("/explanation/", "/debugging/", "/suggestions/", "/analyze/") and ip != "testclient":
73-
check_rate_limit(ip)
82+
if request.url.path in ("/explanation/", "/debugging/", "/suggestions/", "/analyze/"):
83+
remaining = check_rate_limit(ip)
84+
if remaining < 0:
85+
elapsed = (time.perf_counter() - start) * 1000
86+
headers = rate_limit_headers(0)
87+
headers["Retry-After"] = str(RATE_LIMIT_WINDOW_SECONDS)
88+
headers["X-Process-Time-Ms"] = f"{elapsed:.2f}"
89+
headers["X-QyverixAI-Version"] = "3.0.0"
90+
return JSONResponse(
91+
status_code=429,
92+
content={
93+
"detail": f"Rate limit exceeded. Max {RATE_LIMIT} requests/minute."
94+
},
95+
headers=headers,
96+
)
7497

7598
response = await call_next(request)
7699
elapsed = (time.perf_counter() - start) * 1000
100+
response.headers.update(rate_limit_headers(remaining))
77101
response.headers["X-Process-Time-Ms"] = f"{elapsed:.2f}"
78102
response.headers["X-QyverixAI-Version"] = "3.0.0"
79103
return response

backend/tests/test_endpoints.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,18 @@
44
"""
55
import pytest
66
from fastapi.testclient import TestClient
7-
from app.main import _request_counts
87
import sys, os
98
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
10-
from app.main import app
9+
from app import main as app_main
1110

12-
client = TestClient(app)
11+
client = TestClient(app_main.app)
12+
13+
14+
@pytest.fixture(autouse=True)
15+
def reset_rate_limit_state():
16+
app_main._request_counts.clear()
17+
yield
18+
app_main._request_counts.clear()
1319

1420

1521
# ── Fixtures ──────────────────────────────────────────────────────────────────
@@ -152,6 +158,29 @@ def test_health():
152158
assert r.json()["status"] == "ok"
153159

154160

161+
def test_rate_limit_headers_on_success_response():
162+
r = client.get("/")
163+
assert r.status_code == 200
164+
assert r.headers["X-RateLimit-Limit"] == str(app_main.RATE_LIMIT)
165+
assert r.headers["X-RateLimit-Remaining"] == str(app_main.RATE_LIMIT)
166+
167+
168+
def test_rate_limit_returns_429_with_retry_after_header():
169+
payload = {"code": "print('hello')", "language": "python"}
170+
171+
for expected_remaining in range(app_main.RATE_LIMIT - 1, -1, -1):
172+
r = client.post("/debugging/", json=payload)
173+
assert r.status_code == 200
174+
assert r.headers["X-RateLimit-Limit"] == str(app_main.RATE_LIMIT)
175+
assert r.headers["X-RateLimit-Remaining"] == str(expected_remaining)
176+
177+
r = client.post("/debugging/", json=payload)
178+
assert r.status_code == 429
179+
assert r.headers["Retry-After"] == str(app_main.RATE_LIMIT_WINDOW_SECONDS)
180+
assert r.headers["X-RateLimit-Limit"] == str(app_main.RATE_LIMIT)
181+
assert r.headers["X-RateLimit-Remaining"] == "0"
182+
183+
155184
# ── Explanation ───────────────────────────────────────────────────────────────
156185
def test_explanation_python():
157186
r = client.post("/explanation/", json={"code": PYTHON_CLEAN, "language": "python"})
@@ -194,7 +223,7 @@ def test_explanation_empty_code():
194223
def test_explanation_too_long():
195224
r = client.post("/explanation/", json={"code": "x" * 60000})
196225
assert r.status_code == 422
197-
226+
198227
def test_explanation_typescript():
199228
r = client.post("/explanation/", json={"code": TS_CODE, "language": "typescript"})
200229
assert r.status_code == 200
@@ -422,6 +451,6 @@ def test_unicode_code():
422451
assert r.status_code == 200
423452

424453

425-
_request_counts.clear()
454+
def test_single_line_code():
426455
r = client.post("/analyze/", json={"code": "print('hello')"})
427-
assert r.status_code == 200
456+
assert r.status_code == 200

0 commit comments

Comments
 (0)