NousResearch/hermes-agent
API server rate limiting and DoS protection
Cluster 7483 · gateway. Data as of
Nothing open on GitHub. Fix merged in #50007 on 21 Jun 2026. Check it covers your case.
The short answer
0open issues
0open PRs
3items grouped
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #50007. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #50007. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These members address the lack of rate limiting on API server endpoints, covering both burst request limiting and a configurable cap on concurrent runs to mitigate brute-force and denial-of-service risks.
Open issues0
Open PRs0
Fixes on record
-
#50007 feat(api-server): configurable concurrent-run cap to prevent DoS (#7483)
files:
gateway/platforms/api_server.py,hermes_cli/config.py,tests/gateway/test_api_server.pyShow the diff
-
gateway/platforms/api_server.py@@ -782,6 +782,15 @@ def __init__(self, config: PlatformConfig): # 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 @@ def _parse_cors_origins(value: Any) -> tuple[str, ...]: 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. @@ -1748,6 +1781,11 @@ async def _handle_chat_completions(self, request: "web.Request") -> "web.Respons 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() @@ -2817,6 +2855,11 @@ async def _handle_responses(self, request: "web.Request") -> "web.Response": 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: @@ -3568,6 +3611,31 @@ def _extract_output_items(result: Dict[str, Any], start_index: int = 0) -> List[ # 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, @@ -3636,13 +3704,16 @@ def _run(): 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 @@ -3718,12 +3789,11 @@ async def _handle_runs(self, request: "web.Request") -> "web.Response": 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() -
hermes_cli/config.py@@ -2511,6 +2511,18 @@ def _ensure_hermes_home_managed(home: Path): # multi-tool agent turn. Bridged to HERMES_MEDIA_TRUST_RECENT_SECONDS. # Only consulted when ``strict`` is true. "trust_recent_files_seconds": 600, + + # OpenAI-compatible API server platform + # (gateway/platforms/api_server.py). + "api_server": { + # Maximum number of agent runs the API server will service + # concurrently. Requests to /v1/chat/completions, /v1/responses, + # and /v1/runs that arrive while this many runs are already + # in flight are rejected with HTTP 429 + a Retry-After header, + # bounding CPU / memory / upstream-LLM-quota exhaustion from a + # request flood. Set to 0 to disable the cap entirely. + "max_concurrent_runs": 10, + }, }, # Real-time token streaming to messaging platforms (Telegram, Discord, -
tests/gateway/test_api_server.py@@ -420,6 +420,63 @@ def test_malformed_auth_header_returns_401(self): assert result.status == 401 +# --------------------------------------------------------------------------- +# Concurrency cap (gateway.api_server.max_concurrent_runs) — #7483 +# --------------------------------------------------------------------------- + + +class TestConcurrencyCap: + def test_resolve_defaults_to_10_when_unset(self): + with patch("hermes_cli.config.load_config", return_value={}): + assert APIServerAdapter._resolve_max_concurrent_runs() == 10 + + def test_resolve_reads_config_value(self): + cfg = {"gateway": {"api_server": {"max_concurrent_runs": 3}}} + with patch("hermes_cli.config.load_config", return_value=cfg): + assert APIServerAdapter._resolve_max_concurrent_runs() == 3 + + def test_resolve_clamps_negative_to_zero(self): + cfg = {"gateway": {"api_server": {"max_concurrent_runs": -5}}} + with patch("hermes_cli.config.load_config", return_value=cfg): + assert APIServerAdapter._resolve_max_concurrent_runs() == 0 + + def test_resolve_malformed_falls_back_to_default(self): + cfg = {"gateway": {"api_server": {"max_concurrent_runs": "not-an-int"}}} + with patch("hermes_cli.config.load_config", return_value=cfg): + assert APIServerAdapter._resolve_max_concurrent_runs() == 10 + + def test_under_cap_returns_none(self): + adapter = _make_adapter() + adapter._max_concurrent_runs = 5 + adapter._inflight_agent_runs = 2 + assert adapter._concurrency_limited_response() is None + + def test_at_cap_returns_429_with_retry_after(self): + adapter = _make_adapter() + adapter._max_concurrent_runs = 3 + adapter._inflight_agent_runs = 3 + resp = adapter._concurrency_limited_response() + assert resp is not None + assert resp.status == 429 + assert resp.headers.get("Retry-After") + + def test_cap_counts_both_buckets(self): + # /v1/runs (tracked by _run_streams) + chat/responses (inflight) + adapter = _make_adapter() + adapter._max_concurrent_runs = 4 + adapter._inflight_agent_runs = 2 + adapter._run_streams = {"r1": object(), "r2": object()} + resp = adapter._concurrency_limited_response() + assert resp is not None + assert resp.status == 429 + + def test_zero_disables_cap(self): + adapter = _make_adapter() + adapter._max_concurrent_runs = 0 + adapter._inflight_agent_runs = 9999 + assert adapter._concurrency_limited_response() is None + + # --------------------------------------------------------------------------- # Helpers for HTTP tests # ---------------------------------------------------------------------------
-
1 PR closed without merging
From the close comments: superseded by another PR 1
-
#26820 fix(api-server): rate limit request bursts · superseded by another PR →
#50007,#7483
Closed issues · 1
- Closed#7483[Security] No rate limiting on API server endpoints enables brute-force and DoS
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #50007
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 21 Jun 2026