Skip to content

Commit 435c734

Browse files
jopemachineclaude
andcommitted
feat(BA-7365): add a per-user rate limit middleware to the web server
Requests proxied to the manager (`/func/*`) are counted against the login user's rolling counter and rejected with HTTP 429 once the session's rate limit is passed, so floods no longer reach the manager. The middleware is registered after `setup_session()` because it reads the session storage the session middleware installs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ef4b9b6 commit 435c734

5 files changed

Lines changed: 254 additions & 1 deletion

File tree

changes/13771.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Reject over-limit requests at the web server with HTTP 429 before they are proxied to the manager, counting them against the requesting user's rate limit.

src/ai/backend/manager/api/rest/ratelimit/handler.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
This module provides the ``rlim_middleware`` function which is installed
44
as a global aiohttp middleware. There are no route handlers — rate
55
limiting is applied transparently to all authorized requests.
6+
7+
The rolling counter is keyed by the authenticated user, the same counter the
8+
web server's limiter uses. This is the fallback guard for clients that reach
9+
the manager directly; requests proxied by the web server are already limited
10+
there.
611
"""
712

813
from __future__ import annotations

src/ai/backend/web/ratelimit.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Per-user rate limiting for requests proxied to the manager.
2+
3+
The same rolling counter and limit value as the manager-side rate limiter
4+
(``manager/api/rest/ratelimit``): the limit is the keypair ``rate_limit``
5+
delivered at login (``None`` means unlimited), and the counter is keyed by the
6+
login user, so holding multiple keypairs does not multiply the allowance.
7+
Over-limit requests are rejected with HTTP 429 at the web server, before they
8+
reach the manager.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import json
14+
import logging
15+
import uuid
16+
from collections.abc import Awaitable, Callable
17+
from typing import Final
18+
19+
from aiohttp import web
20+
21+
from ai.backend.common.clients.valkey_client.valkey_rate_limit.client import ValkeyRateLimitClient
22+
from ai.backend.common.identifier.user import UserID
23+
from ai.backend.common.web.session import get_session
24+
from ai.backend.logging import BraceStyleAdapter
25+
26+
log: Final = BraceStyleAdapter(logging.getLogger(__spec__.name))
27+
28+
_rlim_window: Final = 60 * 15
29+
_RATE_LIMITED_PATH_PREFIX: Final = "/func/"
30+
31+
32+
@web.middleware
33+
async def rate_limit_middleware(
34+
request: web.Request,
35+
handler: Callable[[web.Request], Awaitable[web.StreamResponse]],
36+
) -> web.StreamResponse:
37+
if not request.path.startswith(_RATE_LIMITED_PATH_PREFIX):
38+
return await handler(request)
39+
session = await get_session(request)
40+
if not session.get("authenticated", False):
41+
return await handler(request)
42+
token = session.get("token") or {}
43+
raw_user_id = token.get("user_id")
44+
if raw_user_id is None:
45+
# Session created against a manager that does not send the user ID yet.
46+
return await handler(request)
47+
rate_limit = token.get("rate_limit")
48+
49+
valkey_client: ValkeyRateLimitClient = request.app["valkey_rate_limit"]
50+
rolling_count = await valkey_client.execute_rate_limit_logic(
51+
user_id=UserID(uuid.UUID(raw_user_id)),
52+
window=_rlim_window,
53+
)
54+
remaining = max(rate_limit - rolling_count, 0) if rate_limit is not None else rolling_count
55+
rlim_headers = {
56+
"X-RateLimit-Limit": str(rate_limit),
57+
"X-RateLimit-Remaining": str(remaining),
58+
"X-RateLimit-Window": str(_rlim_window),
59+
}
60+
if rate_limit is not None and rolling_count > rate_limit:
61+
return web.HTTPTooManyRequests(
62+
text=json.dumps({
63+
"type": "https://api.backend.ai/probs/rate-limit-exceeded",
64+
"title": "You have reached your API query rate limit.",
65+
}),
66+
content_type="application/problem+json",
67+
headers=rlim_headers,
68+
)
69+
response = await handler(request)
70+
if not response.prepared:
71+
response.headers.update(rlim_headers)
72+
return response

src/ai/backend/web/server.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,9 @@
4747
from ai.backend.client.v2.registry import BackendAIClientRegistry
4848
from ai.backend.common import config
4949
from ai.backend.common.clients.http_client.client_pool import ClientPool
50+
from ai.backend.common.clients.valkey_client.valkey_rate_limit.client import ValkeyRateLimitClient
5051
from ai.backend.common.clients.valkey_client.valkey_session.client import ValkeySessionClient
51-
from ai.backend.common.defs import REDIS_STATISTICS_DB, RedisRole
52+
from ai.backend.common.defs import REDIS_RATE_LIMIT_DB, REDIS_STATISTICS_DB, RedisRole
5253
from ai.backend.common.dto.internal.health import (
5354
ConnectivityCheckResponse,
5455
HealthResponse,
@@ -92,6 +93,7 @@
9293
ManagerPoolGateHealthChecker,
9394
)
9495
from ai.backend.web.config.unified import EventLoopType, ServiceMode, WebServerUnifiedConfig
96+
from ai.backend.web.ratelimit import rate_limit_middleware
9597
from ai.backend.web.security import SecurityPolicy, csp_nonce_var, security_policy_middleware
9698

9799
from . import __version__, user_agent
@@ -857,6 +859,13 @@ async def redis_ctx(
857859
# Keep app["redis"] key for compatibility
858860
app["redis"] = valkey_session_client
859861

862+
valkey_rate_limit_client = await ValkeyRateLimitClient.create(
863+
valkey_target=valkey_profile_target.profile_target(RedisRole.RATE_LIMIT),
864+
db_id=REDIS_RATE_LIMIT_DB,
865+
human_readable_name="web.ratelimit",
866+
)
867+
app["valkey_rate_limit"] = valkey_rate_limit_client
868+
860869
if pidx == 0 and config.session.flush_on_startup:
861870
await valkey_session_client.flush_all_sessions()
862871
log.info("flushed session storage.")
@@ -869,9 +878,13 @@ async def redis_ctx(
869878
secure=config.security.cookie_secure,
870879
)
871880
setup_session(app, redis_storage)
881+
# Must come after ``setup_session()``: the session middleware it appends is what
882+
# populates the request with the session storage the rate limiter reads.
883+
app.middlewares.append(rate_limit_middleware)
872884
try:
873885
yield
874886
finally:
887+
await valkey_rate_limit_client.close()
875888
await valkey_session_client.close()
876889

877890

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
from __future__ import annotations
2+
3+
import uuid
4+
from dataclasses import dataclass
5+
from typing import Any
6+
from unittest.mock import AsyncMock
7+
8+
import pytest
9+
from aiohttp import web
10+
from aiohttp.test_utils import make_mocked_request
11+
from pytest_mock import MockerFixture
12+
13+
from ai.backend.common.clients.valkey_client.valkey_rate_limit.client import ValkeyRateLimitClient
14+
from ai.backend.common.identifier.user import UserID
15+
from ai.backend.web import ratelimit
16+
from ai.backend.web.ratelimit import rate_limit_middleware
17+
18+
_USER_ID = UserID(uuid.UUID("12345678-1234-5678-1234-567812345678"))
19+
_RATE_LIMIT = 1000
20+
21+
22+
@pytest.fixture
23+
def mock_valkey_rate_limit_client() -> AsyncMock:
24+
client = AsyncMock(spec=ValkeyRateLimitClient)
25+
client.execute_rate_limit_logic.return_value = 1
26+
return client
27+
28+
29+
@pytest.fixture
30+
def handler_response() -> web.Response:
31+
return web.Response(text="ok")
32+
33+
34+
@pytest.fixture
35+
def handler(handler_response: web.Response) -> AsyncMock:
36+
return AsyncMock(return_value=handler_response)
37+
38+
39+
@dataclass(frozen=True)
40+
class _PassThroughCase:
41+
id: str
42+
path: str
43+
session: dict[str, Any]
44+
45+
46+
@pytest.mark.parametrize(
47+
"case",
48+
[
49+
_PassThroughCase(
50+
id="non-proxied-path",
51+
path="/server/login",
52+
session={
53+
"authenticated": True,
54+
"token": {"user_id": str(_USER_ID), "rate_limit": _RATE_LIMIT},
55+
},
56+
),
57+
_PassThroughCase(
58+
id="unauthenticated",
59+
path="/func/session",
60+
session={},
61+
),
62+
_PassThroughCase(
63+
id="no-user-id-in-token",
64+
path="/func/session",
65+
session={"authenticated": True, "token": {"access_key": "AKTEST"}},
66+
),
67+
],
68+
ids=lambda case: case.id,
69+
)
70+
async def test_pass_through_without_rate_limiting(
71+
case: _PassThroughCase,
72+
mocker: MockerFixture,
73+
mock_valkey_rate_limit_client: AsyncMock,
74+
handler: AsyncMock,
75+
handler_response: web.Response,
76+
) -> None:
77+
mocker.patch.object(ratelimit, "get_session", AsyncMock(return_value=case.session))
78+
request = make_mocked_request(
79+
"GET", case.path, app={"valkey_rate_limit": mock_valkey_rate_limit_client}
80+
)
81+
82+
response = await rate_limit_middleware(request, handler)
83+
84+
assert response is handler_response
85+
handler.assert_awaited_once_with(request)
86+
mock_valkey_rate_limit_client.execute_rate_limit_logic.assert_not_called()
87+
assert "X-RateLimit-Limit" not in response.headers
88+
89+
90+
async def test_counts_request_per_user_and_sets_headers(
91+
mocker: MockerFixture,
92+
mock_valkey_rate_limit_client: AsyncMock,
93+
handler: AsyncMock,
94+
handler_response: web.Response,
95+
) -> None:
96+
session = {
97+
"authenticated": True,
98+
"token": {"user_id": str(_USER_ID), "rate_limit": _RATE_LIMIT},
99+
}
100+
mocker.patch.object(ratelimit, "get_session", AsyncMock(return_value=session))
101+
request = make_mocked_request(
102+
"GET", "/func/session", app={"valkey_rate_limit": mock_valkey_rate_limit_client}
103+
)
104+
105+
response = await rate_limit_middleware(request, handler)
106+
107+
assert response is handler_response
108+
mock_valkey_rate_limit_client.execute_rate_limit_logic.assert_awaited_once_with(
109+
user_id=_USER_ID,
110+
window=ratelimit._rlim_window,
111+
)
112+
assert response.headers["X-RateLimit-Limit"] == str(_RATE_LIMIT)
113+
assert response.headers["X-RateLimit-Remaining"] == str(_RATE_LIMIT - 1)
114+
assert response.headers["X-RateLimit-Window"] == str(ratelimit._rlim_window)
115+
116+
117+
async def test_rejects_over_limit_request_with_429(
118+
mocker: MockerFixture,
119+
mock_valkey_rate_limit_client: AsyncMock,
120+
handler: AsyncMock,
121+
) -> None:
122+
session = {
123+
"authenticated": True,
124+
"token": {"user_id": str(_USER_ID), "rate_limit": _RATE_LIMIT},
125+
}
126+
mocker.patch.object(ratelimit, "get_session", AsyncMock(return_value=session))
127+
mock_valkey_rate_limit_client.execute_rate_limit_logic.return_value = _RATE_LIMIT + 1
128+
request = make_mocked_request(
129+
"GET", "/func/session", app={"valkey_rate_limit": mock_valkey_rate_limit_client}
130+
)
131+
132+
response = await rate_limit_middleware(request, handler)
133+
134+
handler.assert_not_called()
135+
assert isinstance(response, web.HTTPTooManyRequests)
136+
assert response.content_type == "application/problem+json"
137+
assert response.headers["X-RateLimit-Limit"] == str(_RATE_LIMIT)
138+
assert response.headers["X-RateLimit-Remaining"] == "0"
139+
140+
141+
async def test_null_rate_limit_counts_but_never_rejects(
142+
mocker: MockerFixture,
143+
mock_valkey_rate_limit_client: AsyncMock,
144+
handler: AsyncMock,
145+
handler_response: web.Response,
146+
) -> None:
147+
session = {
148+
"authenticated": True,
149+
"token": {"user_id": str(_USER_ID), "rate_limit": None},
150+
}
151+
mocker.patch.object(ratelimit, "get_session", AsyncMock(return_value=session))
152+
mock_valkey_rate_limit_client.execute_rate_limit_logic.return_value = 10_000_000
153+
request = make_mocked_request(
154+
"GET", "/func/session", app={"valkey_rate_limit": mock_valkey_rate_limit_client}
155+
)
156+
157+
response = await rate_limit_middleware(request, handler)
158+
159+
assert response is handler_response
160+
handler.assert_awaited_once_with(request)
161+
mock_valkey_rate_limit_client.execute_rate_limit_logic.assert_awaited_once()
162+
assert response.headers["X-RateLimit-Remaining"] == "10000000"

0 commit comments

Comments
 (0)