feat(api-server): configurable concurrent-run cap to prevent DoS (#50007)
The OpenAI-compatible API server only enforced a hardcoded cap of 10 concurrent runs on /v1/runs, leaving /v1/chat/completions and /v1/responses unbounded — a request flood could exhaust CPU, memory, and upstream LLM quota (#7483). - Add gateway.api_server.max_concurrent_runs (config.yaml, default 10, 0 disables). No env var. - Shared concurrency gate across all three agent-serving endpoints, counting both the chat/responses in-flight counter and the /v1/runs stream set. Returns OpenAI-style 429 + Retry-After when at the cap. - Remove the dead hardcoded _MAX_CONCURRENT_RUNS class attribute. Closes #7483.
This commit is contained in:
parent
99233faf78
commit
e499d69e3e
3 changed files with 147 additions and 8 deletions
|
|
@ -782,6 +782,15 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
# in-flight run by run_id.
|
||||
self._run_approval_sessions: Dict[str, str] = {}
|
||||
self._session_db: Optional[Any] = None # Lazy-init SessionDB for session continuity
|
||||
# Concurrency cap shared across all agent-serving endpoints
|
||||
# (/v1/chat/completions, /v1/responses, /v1/runs). Read from
|
||||
# config.yaml gateway.api_server.max_concurrent_runs; 0 disables
|
||||
# the cap. Bounds CPU / memory / upstream-LLM-quota exhaustion
|
||||
# from a request flood (#7483).
|
||||
self._max_concurrent_runs: int = self._resolve_max_concurrent_runs()
|
||||
# Number of in-flight runs on the non-streaming chat/responses paths
|
||||
# (the /v1/runs path tracks its own in-flight set via _run_streams).
|
||||
self._inflight_agent_runs: int = 0
|
||||
|
||||
@staticmethod
|
||||
def _parse_cors_origins(value: Any) -> tuple[str, ...]:
|
||||
|
|
@ -798,6 +807,30 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
|
||||
return tuple(str(item).strip() for item in items if str(item).strip())
|
||||
|
||||
@staticmethod
|
||||
def _resolve_max_concurrent_runs() -> int:
|
||||
"""Read the concurrent-run cap from config.yaml (0 disables).
|
||||
|
||||
gateway.api_server.max_concurrent_runs. Falls back to the historical
|
||||
default of 10 when unset or malformed. Negative values are clamped
|
||||
to 0 (disabled).
|
||||
"""
|
||||
default = 10
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config
|
||||
|
||||
raw = cfg_get(
|
||||
load_config(),
|
||||
"gateway",
|
||||
"api_server",
|
||||
"max_concurrent_runs",
|
||||
default=default,
|
||||
)
|
||||
value = int(raw)
|
||||
except Exception:
|
||||
return default
|
||||
return max(0, value)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_model_name(explicit: str) -> str:
|
||||
"""Derive the advertised model name for /v1/models.
|
||||
|
|
@ -1767,6 +1800,11 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
if auth_err:
|
||||
return auth_err
|
||||
|
||||
# Bound total in-flight agent runs (configurable; #7483).
|
||||
limited = self._concurrency_limited_response()
|
||||
if limited is not None:
|
||||
return limited
|
||||
|
||||
# Parse request body
|
||||
try:
|
||||
body = await request.json()
|
||||
|
|
@ -2836,6 +2874,11 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
if auth_err:
|
||||
return auth_err
|
||||
|
||||
# Bound total in-flight agent runs (configurable; #7483).
|
||||
limited = self._concurrency_limited_response()
|
||||
if limited is not None:
|
||||
return limited
|
||||
|
||||
# Long-term memory scope header (see chat_completions for details).
|
||||
gateway_session_key, key_err = self._parse_session_key_header(request)
|
||||
if key_err is not None:
|
||||
|
|
@ -3587,6 +3630,31 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
# Agent execution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _concurrency_limited_response(self) -> Optional["web.Response"]:
|
||||
"""Return a 429 response if the concurrent-run cap is reached, else None.
|
||||
|
||||
The cap bounds total in-flight agent activity across every
|
||||
agent-serving endpoint: the non-streaming chat/responses paths
|
||||
(tracked by ``_inflight_agent_runs``) plus the ``/v1/runs`` streaming
|
||||
path (tracked by ``_run_streams``). A configured value of 0 disables
|
||||
the cap entirely.
|
||||
"""
|
||||
limit = self._max_concurrent_runs
|
||||
if limit <= 0:
|
||||
return None
|
||||
inflight = self._inflight_agent_runs + len(self._run_streams)
|
||||
if inflight >= limit:
|
||||
return web.json_response(
|
||||
_openai_error(
|
||||
f"Too many concurrent runs (max {limit})",
|
||||
err_type="rate_limit_error",
|
||||
code="rate_limit_exceeded",
|
||||
),
|
||||
status=429,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return None
|
||||
|
||||
async def _run_agent(
|
||||
self,
|
||||
user_message: str,
|
||||
|
|
@ -3655,13 +3723,16 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
finally:
|
||||
clear_session_vars(tokens)
|
||||
|
||||
return await loop.run_in_executor(None, _run)
|
||||
self._inflight_agent_runs += 1
|
||||
try:
|
||||
return await loop.run_in_executor(None, _run)
|
||||
finally:
|
||||
self._inflight_agent_runs -= 1
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /v1/runs — structured event streaming
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_MAX_CONCURRENT_RUNS = 10 # Prevent unbounded resource allocation
|
||||
_RUN_STREAM_TTL = 300 # seconds before orphaned runs are swept
|
||||
_RUN_STATUS_TTL = 3600 # seconds to retain terminal run status for polling
|
||||
|
||||
|
|
@ -3737,12 +3808,11 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
if key_err is not None:
|
||||
return key_err
|
||||
|
||||
# Enforce concurrency limit
|
||||
if len(self._run_streams) >= self._MAX_CONCURRENT_RUNS:
|
||||
return web.json_response(
|
||||
_openai_error(f"Too many concurrent runs (max {self._MAX_CONCURRENT_RUNS})", code="rate_limit_exceeded"),
|
||||
status=429,
|
||||
)
|
||||
# Enforce concurrency limit (shared across all agent-serving
|
||||
# endpoints; configurable via gateway.api_server.max_concurrent_runs).
|
||||
limited = self._concurrency_limited_response()
|
||||
if limited is not None:
|
||||
return limited
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue