{"repo":"NousResearch/hermes-agent","public_id":"7483","display_label":"API server rate limiting and DoS protection","summary":"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.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50007,"merged_at":"2026-06-21T14:26:03+00:00"},"resolution":{"state":"resolved","by":"pr-50007"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T14:26:03+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":7483,"pr_number":50007,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-21T14:26:03+00:00"}],"members":[{"kind":"issue","number":7483,"title":"[Security] No rate limiting on API server endpoints enables brute-force and DoS","url":null,"state":"closed"},{"kind":"pr","number":26820,"title":"fix(api-server): rate limit request bursts","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50007","number":50007},{"kind":"pr","value":"#7483","number":7483}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":50007,"title":"feat(api-server): configurable concurrent-run cap to prevent DoS (#7483)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/api_server.py","hermes_cli/config.py","tests/gateway/test_api_server.py"],"fix_hunks":[{"file":"gateway/platforms/api_server.py","hunks":["@@ -782,6 +782,15 @@ def __init__(self, config: PlatformConfig):\n         # in-flight run by run_id.\n         self._run_approval_sessions: Dict[str, str] = {}\n         self._session_db: Optional[Any] = None  # Lazy-init SessionDB for session continuity\n+        # Concurrency cap shared across all agent-serving endpoints\n+        # (/v1/chat/completions, /v1/responses, /v1/runs). Read from\n+        # config.yaml gateway.api_server.max_concurrent_runs; 0 disables\n+        # the cap. Bounds CPU / memory / upstream-LLM-quota exhaustion\n+        # from a request flood (#7483).\n+        self._max_concurrent_runs: int = self._resolve_max_concurrent_runs()\n+        # Number of in-flight runs on the non-streaming chat/responses paths\n+        # (the /v1/runs path tracks its own in-flight set via _run_streams).\n+        self._inflight_agent_runs: int = 0\n \n     @staticmethod\n     def _parse_cors_origins(value: Any) -> tuple[str, ...]:","@@ -798,6 +807,30 @@ def _parse_cors_origins(value: Any) -> tuple[str, ...]:\n \n         return tuple(str(item).strip() for item in items if str(item).strip())\n \n+    @staticmethod\n+    def _resolve_max_concurrent_runs() -> int:\n+        \"\"\"Read the concurrent-run cap from config.yaml (0 disables).\n+\n+        gateway.api_server.max_concurrent_runs. Falls back to the historical\n+        default of 10 when unset or malformed. Negative values are clamped\n+        to 0 (disabled).\n+        \"\"\"\n+        default = 10\n+        try:\n+            from hermes_cli.config import cfg_get, load_config\n+\n+            raw = cfg_get(\n+                load_config(),\n+                \"gateway\",\n+                \"api_server\",\n+                \"max_concurrent_runs\",\n+                default=default,\n+            )\n+            value = int(raw)\n+        except Exception:\n+            return default\n+        return max(0, value)\n+\n     @staticmethod\n     def _resolve_model_name(explicit: str) -> str:\n         \"\"\"Derive the advertised model name for /v1/models.","@@ -1748,6 +1781,11 @@ async def _handle_chat_completions(self, request: \"web.Request\") -> \"web.Respons\n         if auth_err:\n             return auth_err\n \n+        # Bound total in-flight agent runs (configurable; #7483).\n+        limited = self._concurrency_limited_response()\n+        if limited is not None:\n+            return limited\n+\n         # Parse request body\n         try:\n             body = await request.json()","@@ -2817,6 +2855,11 @@ async def _handle_responses(self, request: \"web.Request\") -> \"web.Response\":\n         if auth_err:\n             return auth_err\n \n+        # Bound total in-flight agent runs (configurable; #7483).\n+        limited = self._concurrency_limited_response()\n+        if limited is not None:\n+            return limited\n+\n         # Long-term memory scope header (see chat_completions for details).\n         gateway_session_key, key_err = self._parse_session_key_header(request)\n         if key_err is not None:","@@ -3568,6 +3611,31 @@ def _extract_output_items(result: Dict[str, Any], start_index: int = 0) -> List[\n     # Agent execution\n     # ------------------------------------------------------------------\n \n+    def _concurrency_limited_response(self) -> Optional[\"web.Response\"]:\n+        \"\"\"Return a 429 response if the concurrent-run cap is reached, else None.\n+\n+        The cap bounds total in-flight agent activity across every\n+        agent-serving endpoint: the non-streaming chat/responses paths\n+        (tracked by ``_inflight_agent_runs``) plus the ``/v1/runs`` streaming\n+        path (tracked by ``_run_streams``). A configured value of 0 disables\n+        the cap entirely.\n+        \"\"\"\n+        limit = self._max_concurrent_runs\n+        if limit <= 0:\n+            return None\n+        inflight = self._inflight_agent_runs + len(self._run_streams)\n+        if inflight >= limit:\n+            return web.json_response(\n+                _openai_error(\n+                    f\"Too many concurrent runs (max {limit})\",\n+                    err_type=\"rate_limit_error\",\n+                    code=\"rate_limit_exceeded\",\n+                ),\n+                status=429,\n+                headers={\"Retry-After\": \"1\"},\n+            )\n+        return None\n+\n     async def _run_agent(\n         self,\n         user_message: str,","@@ -3636,13 +3704,16 @@ def _run():\n             finally:\n                 clear_session_vars(tokens)\n \n-        return await loop.run_in_executor(None, _run)\n+        self._inflight_agent_runs += 1\n+        try:\n+            return await loop.run_in_executor(None, _run)\n+        finally:\n+            self._inflight_agent_runs -= 1\n \n     # ------------------------------------------------------------------\n     # /v1/runs — structured event streaming\n     # ------------------------------------------------------------------\n \n-    _MAX_CONCURRENT_RUNS = 10  # Prevent unbounded resource allocation\n     _RUN_STREAM_TTL = 300  # seconds before orphaned runs are swept\n     _RUN_STATUS_TTL = 3600  # seconds to retain terminal run status for polling\n ","@@ -3718,12 +3789,11 @@ async def _handle_runs(self, request: \"web.Request\") -> \"web.Response\":\n         if key_err is not None:\n             return key_err\n \n-        # Enforce concurrency limit\n-        if len(self._run_streams) >= self._MAX_CONCURRENT_RUNS:\n-            return web.json_response(\n-                _openai_error(f\"Too many concurrent runs (max {self._MAX_CONCURRENT_RUNS})\", code=\"rate_limit_exceeded\"),\n-                status=429,\n-            )\n+        # Enforce concurrency limit (shared across all agent-serving\n+        # endpoints; configurable via gateway.api_server.max_concurrent_runs).\n+        limited = self._concurrency_limited_response()\n+        if limited is not None:\n+            return limited\n \n         try:\n             body = await request.json()"]},{"file":"hermes_cli/config.py","hunks":["@@ -2511,6 +2511,18 @@ def _ensure_hermes_home_managed(home: Path):\n         # multi-tool agent turn. Bridged to HERMES_MEDIA_TRUST_RECENT_SECONDS.\n         # Only consulted when ``strict`` is true.\n         \"trust_recent_files_seconds\": 600,\n+\n+        # OpenAI-compatible API server platform\n+        # (gateway/platforms/api_server.py).\n+        \"api_server\": {\n+            # Maximum number of agent runs the API server will service\n+            # concurrently. Requests to /v1/chat/completions, /v1/responses,\n+            # and /v1/runs that arrive while this many runs are already\n+            # in flight are rejected with HTTP 429 + a Retry-After header,\n+            # bounding CPU / memory / upstream-LLM-quota exhaustion from a\n+            # request flood. Set to 0 to disable the cap entirely.\n+            \"max_concurrent_runs\": 10,\n+        },\n     },\n \n     # Real-time token streaming to messaging platforms (Telegram, Discord,"]},{"file":"tests/gateway/test_api_server.py","hunks":["@@ -420,6 +420,63 @@ def test_malformed_auth_header_returns_401(self):\n         assert result.status == 401\n \n \n+# ---------------------------------------------------------------------------\n+# Concurrency cap (gateway.api_server.max_concurrent_runs) — #7483\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestConcurrencyCap:\n+    def test_resolve_defaults_to_10_when_unset(self):\n+        with patch(\"hermes_cli.config.load_config\", return_value={}):\n+            assert APIServerAdapter._resolve_max_concurrent_runs() == 10\n+\n+    def test_resolve_reads_config_value(self):\n+        cfg = {\"gateway\": {\"api_server\": {\"max_concurrent_runs\": 3}}}\n+        with patch(\"hermes_cli.config.load_config\", return_value=cfg):\n+            assert APIServerAdapter._resolve_max_concurrent_runs() == 3\n+\n+    def test_resolve_clamps_negative_to_zero(self):\n+        cfg = {\"gateway\": {\"api_server\": {\"max_concurrent_runs\": -5}}}\n+        with patch(\"hermes_cli.config.load_config\", return_value=cfg):\n+            assert APIServerAdapter._resolve_max_concurrent_runs() == 0\n+\n+    def test_resolve_malformed_falls_back_to_default(self):\n+        cfg = {\"gateway\": {\"api_server\": {\"max_concurrent_runs\": \"not-an-int\"}}}\n+        with patch(\"hermes_cli.config.load_config\", return_value=cfg):\n+            assert APIServerAdapter._resolve_max_concurrent_runs() == 10\n+\n+    def test_under_cap_returns_none(self):\n+        adapter = _make_adapter()\n+        adapter._max_concurrent_runs = 5\n+        adapter._inflight_agent_runs = 2\n+        assert adapter._concurrency_limited_response() is None\n+\n+    def test_at_cap_returns_429_with_retry_after(self):\n+        adapter = _make_adapter()\n+        adapter._max_concurrent_runs = 3\n+        adapter._inflight_agent_runs = 3\n+        resp = adapter._concurrency_limited_response()\n+        assert resp is not None\n+        assert resp.status == 429\n+        assert resp.headers.get(\"Retry-After\")\n+\n+    def test_cap_counts_both_buckets(self):\n+        # /v1/runs (tracked by _run_streams) + chat/responses (inflight)\n+        adapter = _make_adapter()\n+        adapter._max_concurrent_runs = 4\n+        adapter._inflight_agent_runs = 2\n+        adapter._run_streams = {\"r1\": object(), \"r2\": object()}\n+        resp = adapter._concurrency_limited_response()\n+        assert resp is not None\n+        assert resp.status == 429\n+\n+    def test_zero_disables_cap(self):\n+        adapter = _make_adapter()\n+        adapter._max_concurrent_runs = 0\n+        adapter._inflight_agent_runs = 9999\n+        assert adapter._concurrency_limited_response() is None\n+\n+\n # ---------------------------------------------------------------------------\n # Helpers for HTTP tests\n # ---------------------------------------------------------------------------\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}