Skip to content

Commit b4cadb0

Browse files
authored
Create test_ratelimit.py
1 parent 6b8e206 commit b4cadb0

1 file changed

Lines changed: 131 additions & 0 deletions

File tree

‎tests/test_ratelimit.py‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Rate limiting on auth (per-IP) and run (per-user) endpoints."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
from fastapi.testclient import TestClient
7+
8+
from app.infra.config import get_settings
9+
10+
11+
class TestAuthRateLimit:
12+
def test_login_throttled_after_limit(self, client: TestClient, monkeypatch) -> None:
13+
monkeypatch.setattr(get_settings(), "rate_limit_auth", 3)
14+
# wrong creds return 401; the limiter counts every attempt
15+
seen = [
16+
client.post(
17+
"/auth/login", json={"email": "x@y.com", "password": "password-1"}
18+
).status_code
19+
for _ in range(4)
20+
]
21+
assert seen[:3] == [401, 401, 401]
22+
assert seen[3] == 429
23+
24+
def test_429_carries_retry_after(self, client: TestClient, monkeypatch) -> None:
25+
monkeypatch.setattr(get_settings(), "rate_limit_auth", 1)
26+
client.post("/auth/login", json={"email": "a@b.com", "password": "password-1"})
27+
res = client.post("/auth/login", json={"email": "a@b.com", "password": "password-1"})
28+
assert res.status_code == 429
29+
assert res.headers.get("Retry-After") is not None
30+
assert "rate limit" in res.json()["detail"]
31+
32+
def test_register_is_limited_too(self, client: TestClient, monkeypatch) -> None:
33+
monkeypatch.setattr(get_settings(), "rate_limit_auth", 2)
34+
codes = [
35+
client.post(
36+
"/auth/register", json={"email": f"u{i}@e.com", "password": "password-1"}
37+
).status_code
38+
for i in range(3)
39+
]
40+
assert codes[:2] == [201, 201]
41+
assert codes[2] == 429
42+
43+
44+
class TestRunRateLimit:
45+
def _runner_patch(self, monkeypatch):
46+
from app.controllers import manager as manager_mod
47+
from app.controllers.runner import ExecResult, Runner
48+
49+
class QuietRunner(Runner):
50+
def create(self, user_id, conversation_id):
51+
return "sbx"
52+
53+
def destroy(self, sandbox_id):
54+
pass
55+
56+
def exec_run(self, sandbox_id, prompt, run_id, on_line=None, timeout=None):
57+
return ExecResult(
58+
exit_code=0,
59+
stdout='{"type": "result", "answer": "ok", "errors": []}\n',
60+
stderr="",
61+
)
62+
63+
def reap_idle(self, ttl_seconds):
64+
return []
65+
66+
monkeypatch.setattr(manager_mod, "_controller", None)
67+
monkeypatch.setattr(manager_mod, "get_runner", lambda: QuietRunner(), raising=True)
68+
69+
def test_run_submissions_throttled_per_user(
70+
self, client: TestClient, auth_headers: dict, monkeypatch
71+
) -> None:
72+
self._runner_patch(monkeypatch)
73+
monkeypatch.setattr(get_settings(), "rate_limit_runs", 2)
74+
cid = client.post("/conversations", json={"title": "t"}, headers=auth_headers).json()["id"]
75+
codes = [
76+
client.post(
77+
f"/conversations/{cid}/runs", json={"prompt": f"p{i}"}, headers=auth_headers
78+
).status_code
79+
for i in range(3)
80+
]
81+
# 202 accepted until the per-user window fills, then 429
82+
assert codes.count(429) == 1
83+
assert codes[2] == 429
84+
85+
# let the accepted runs' worker threads finalize before the next
86+
# test resets the DB engine (a late finalize would hit a dead engine)
87+
import time
88+
89+
from app.controllers.manager import get_controller
90+
91+
ctrl = get_controller()
92+
deadline = time.time() + 10
93+
while ctrl._active and time.time() < deadline:
94+
time.sleep(0.02)
95+
96+
97+
class TestLimiterUnit:
98+
def test_sliding_window_allows_then_blocks(self) -> None:
99+
from app.infra.ratelimit import SlidingWindowLimiter
100+
101+
lim = SlidingWindowLimiter()
102+
assert lim.check("k", 2, 60)[0] is True
103+
assert lim.check("k", 2, 60)[0] is True
104+
allowed, retry = lim.check("k", 2, 60)
105+
assert allowed is False and retry > 0
106+
107+
def test_keys_are_independent(self) -> None:
108+
from app.infra.ratelimit import SlidingWindowLimiter
109+
110+
lim = SlidingWindowLimiter()
111+
assert lim.check("a", 1, 60)[0] is True
112+
assert lim.check("b", 1, 60)[0] is True # different key, own budget
113+
assert lim.check("a", 1, 60)[0] is False
114+
115+
def test_reset_clears_counters(self) -> None:
116+
from app.infra.ratelimit import SlidingWindowLimiter
117+
118+
lim = SlidingWindowLimiter()
119+
lim.check("k", 1, 60)
120+
assert lim.check("k", 1, 60)[0] is False
121+
lim.reset()
122+
assert lim.check("k", 1, 60)[0] is True
123+
124+
125+
@pytest.fixture(autouse=True)
126+
def _reset_limiter():
127+
from app.infra.ratelimit import limiter
128+
129+
limiter.reset()
130+
yield
131+
limiter.reset()

0 commit comments

Comments
 (0)