Spaces:
Runtime error
Runtime error
feat(query): per-user rate limiting (#14)
Browse files* feat(query): add in-memory sliding window rate limiter
* feat(query): apply rate limit to the query endpoint
* test(query): verify 429 rate limit and per-user isolation
- app/rate_limit.py +33 -0
- app/routers/hubs.py +3 -1
- tests/test_rate_limit.py +42 -0
app/rate_limit.py
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from collections import defaultdict, deque
|
| 3 |
+
from fastapi import Depends
|
| 4 |
+
from uuid import UUID
|
| 5 |
+
from app.models import User
|
| 6 |
+
from app.deps import get_current_user
|
| 7 |
+
from app.errors import AppError
|
| 8 |
+
|
| 9 |
+
# Dictionary mapping user_id -> deque of timestamps
|
| 10 |
+
_hits: dict[UUID, deque] = defaultdict(deque)
|
| 11 |
+
|
| 12 |
+
def check_query_rate(current_user: User = Depends(get_current_user)) -> None:
|
| 13 |
+
"""Sliding window rate limiter: 10 queries per 60 seconds per user."""
|
| 14 |
+
limit = 10
|
| 15 |
+
window = 60
|
| 16 |
+
user_id = current_user.id
|
| 17 |
+
|
| 18 |
+
now = time.monotonic()
|
| 19 |
+
q = _hits[user_id]
|
| 20 |
+
|
| 21 |
+
# Remove timestamps older than the window
|
| 22 |
+
while q and now - q[0] > window:
|
| 23 |
+
q.popleft()
|
| 24 |
+
|
| 25 |
+
if len(q) >= limit:
|
| 26 |
+
raise AppError(
|
| 27 |
+
429,
|
| 28 |
+
"RATE_LIMITED",
|
| 29 |
+
f"At most {limit} queries per {window} seconds.",
|
| 30 |
+
headers={"Retry-After": str(window)}
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
q.append(now)
|
app/routers/hubs.py
CHANGED
|
@@ -19,6 +19,7 @@ from app.services.embeddings import embed_texts
|
|
| 19 |
from app.services.gemini import hub_insights, answer_question, expand_query
|
| 20 |
from app.services.observability import log_ai_call
|
| 21 |
from app.config import settings
|
|
|
|
| 22 |
|
| 23 |
router = APIRouter(prefix="/hubs", tags=["hubs"])
|
| 24 |
|
|
@@ -148,7 +149,8 @@ def query_hub(
|
|
| 148 |
hub_id: UUID,
|
| 149 |
req: QueryRequest,
|
| 150 |
current_user: User = Depends(get_current_user),
|
| 151 |
-
db: Session = Depends(get_db)
|
|
|
|
| 152 |
):
|
| 153 |
hub = repo.get_hub_for_owner(db, hub_id, current_user.id)
|
| 154 |
if not hub:
|
|
|
|
| 19 |
from app.services.gemini import hub_insights, answer_question, expand_query
|
| 20 |
from app.services.observability import log_ai_call
|
| 21 |
from app.config import settings
|
| 22 |
+
from app.rate_limit import check_query_rate
|
| 23 |
|
| 24 |
router = APIRouter(prefix="/hubs", tags=["hubs"])
|
| 25 |
|
|
|
|
| 149 |
hub_id: UUID,
|
| 150 |
req: QueryRequest,
|
| 151 |
current_user: User = Depends(get_current_user),
|
| 152 |
+
db: Session = Depends(get_db),
|
| 153 |
+
_rate_limit: None = Depends(check_query_rate)
|
| 154 |
):
|
| 155 |
hub = repo.get_hub_for_owner(db, hub_id, current_user.id)
|
| 156 |
if not hub:
|
tests/test_rate_limit.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from uuid import uuid4
|
| 3 |
+
from unittest.mock import patch
|
| 4 |
+
from app.rate_limit import _hits
|
| 5 |
+
|
| 6 |
+
def test_query_rate_limit(client, db_session):
|
| 7 |
+
# Clear the in-memory rate limit dictionary between tests
|
| 8 |
+
_hits.clear()
|
| 9 |
+
|
| 10 |
+
client.post("/api/v1/auth/signup", json={"email": "ratelimit@test.com", "password": "Password123"})
|
| 11 |
+
token = client.post("/api/v1/auth/login", json={"email": "ratelimit@test.com", "password": "Password123"}).json()["access_token"]
|
| 12 |
+
headers = {"Authorization": f"Bearer {token}"}
|
| 13 |
+
|
| 14 |
+
hub_res = client.post("/api/v1/hubs", headers={**headers, "X-Idempotency-Key": str(uuid4())}, json={"name": "Rate Limit Hub"})
|
| 15 |
+
hub_id = hub_res.json()["id"]
|
| 16 |
+
|
| 17 |
+
# We mock the AI to be fast so we can make 10 rapid requests
|
| 18 |
+
with patch("app.routers.hubs.expand_query", return_value=[]), \
|
| 19 |
+
patch("app.routers.hubs.embed_texts", return_value=[[0.1]*768]), \
|
| 20 |
+
patch("app.routers.hubs.answer_question", return_value={"answer": "Test", "evidence": None, "grounded": False}):
|
| 21 |
+
|
| 22 |
+
# Make 10 successful queries
|
| 23 |
+
for _ in range(10):
|
| 24 |
+
res = client.post(f"/api/v1/hubs/{hub_id}/query", headers=headers, json={"question": "Test query?"})
|
| 25 |
+
assert res.status_code == 200
|
| 26 |
+
|
| 27 |
+
# The 11th query should be rate limited
|
| 28 |
+
res_11 = client.post(f"/api/v1/hubs/{hub_id}/query", headers=headers, json={"question": "Test query?"})
|
| 29 |
+
assert res_11.status_code == 429
|
| 30 |
+
assert res_11.json()["error"]["code"] == "RATE_LIMITED"
|
| 31 |
+
assert "Retry-After" in res_11.headers
|
| 32 |
+
|
| 33 |
+
# A different user should not be rate limited (per-user isolation)
|
| 34 |
+
client.post("/api/v1/auth/signup", json={"email": "ratelimit2@test.com", "password": "Password123"})
|
| 35 |
+
token2 = client.post("/api/v1/auth/login", json={"email": "ratelimit2@test.com", "password": "Password123"}).json()["access_token"]
|
| 36 |
+
|
| 37 |
+
with patch("app.routers.hubs.expand_query", return_value=[]), \
|
| 38 |
+
patch("app.routers.hubs.embed_texts", return_value=[[0.1]*768]), \
|
| 39 |
+
patch("app.routers.hubs.answer_question", return_value={"answer": "Test", "evidence": None, "grounded": False}):
|
| 40 |
+
|
| 41 |
+
res_other = client.post(f"/api/v1/hubs/{hub_id}/query", headers={"Authorization": f"Bearer {token2}"}, json={"question": "Test query?"})
|
| 42 |
+
assert res_other.status_code == 404 # Because User 2 doesn't own Hub 1
|