fix(rate-limit): stop double-counting requests in middleware

Multiple RateLimitMiddleware instances were each calling
check_allowed() on every request, halving the effective global
limit (200 req/min became ~100). Added path_prefixes and skip_paths
so each instance only checks the paths it owns.

- Auth middleware scoped to /api/v1/auth/login and /api/v1/setup
- History middleware scoped to /api/v1/history
- Global middleware skips auth and history paths
- Updated tests to match single-count behavior
This commit is contained in:
2026-05-15 23:04:02 +02:00
parent 77df5d5d65
commit 7308ff88d6
3 changed files with 92 additions and 45 deletions

View File

@@ -134,24 +134,17 @@ class TestRateLimitMiddleware:
"""Global rate limit should block requests exceeding per-IP limit."""
await _do_setup(client)
# Create a client that mimics a specific IP
# We'll make many requests and see if we hit the limit
limiter = client._transport.app.state.global_rate_limiter
limiter.reset()
# Reduce limit temporarily for testing.
# Each request is checked by two middleware instances, so the
# effective limit is doubled for non-bucket endpoints.
original_max = limiter.max_requests
limiter.max_requests = 7
limiter.max_requests = 3
try:
# First 3 requests should succeed
for i in range(3):
response = await client.get("/api/v1/health")
assert response.status_code == 200, f"Request {i + 1} failed"
# Fourth request should be rate limited
response = await client.get("/api/v1/health")
assert response.status_code == 429
assert response.json()["code"] == "rate_limit_exceeded"
@@ -166,22 +159,47 @@ class TestRateLimitMiddleware:
limiter = client._transport.app.state.global_rate_limiter
limiter.reset()
# Two middleware instances check each request, so the effective
# limit is doubled for non-bucket endpoints.
original_max = limiter.max_requests
limiter.max_requests = 3
limiter.max_requests = 2
try:
# First request succeeds
response = await client.get("/api/v1/health")
assert response.status_code == 200
# Second request is rate limited
response = await client.get("/api/v1/health")
assert response.status_code == 200
response = await client.get("/api/v1/health")
assert response.status_code == 429
assert "Retry-After" in response.headers
retry_after = int(response.headers["Retry-After"])
assert retry_after > 0
assert retry_after <= 60 # Should be less than window
assert retry_after <= 60
finally:
limiter.max_requests = original_max
async def test_auth_bucket_allows_more_requests(self, client: AsyncClient) -> None:
"""Auth endpoints use a dedicated high-rate bucket."""
await _do_setup(client)
limiter = client._transport.app.state.global_rate_limiter
limiter.reset()
# The auth bucket is configured for 1000 req/min; we only need to
# verify that it is *not* the global bucket (200 req/min).
for _ in range(5):
response = await client.post("/api/v1/auth/login", json={"password": "x"})
assert response.status_code in (401, 403, 429)
async def test_history_bucket_allows_more_requests(self, client: AsyncClient) -> None:
"""History endpoints use a dedicated high-rate bucket."""
await _do_setup(client)
limiter = client._transport.app.state.global_rate_limiter
limiter.reset()
for _ in range(5):
response = await client.get("/api/v1/history/bans")
# 401/403 is fine — we just need to confirm we are not 429'd
# by the global limiter.
assert response.status_code != 429