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:
Teknium 2026-06-21 07:26:03 -07:00 committed by GitHub
parent 99233faf78
commit e499d69e3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 147 additions and 8 deletions

View file

@ -420,6 +420,63 @@ class TestAuth:
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
# ---------------------------------------------------------------------------