RamshaAnwar commited on
Commit
85c3a7b
·
unverified ·
1 Parent(s): dd9b17c

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 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