fix(gateway): keep 600s read-timeout default (gate finding — 120s would kill legit long silent tool calls)

Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
This commit is contained in:
nesquena-hermes 2026-07-09 08:03:02 +00:00
parent fcb135a133
commit dfd9b57183
2 changed files with 17 additions and 13 deletions

View file

@ -55,16 +55,20 @@ _GATEWAY_CHAT_BACKENDS = {"gateway", "api_server", "api-server"}
# read timeout ends the turn (surfacing Stop if pressed). It replaces the old flat
# 600s timeout, under which a half-open gateway (TCP open, zero bytes) pinned the
# worker for the full 10 minutes and ignored Stop (cancel is only re-checked
# between SSE lines). 120s is generous for a slow first token / short silent
# server-side tool call while capping the dead-gateway pin far below 600s; no
# protocol heartbeat exists, so this is purely timeout-based. Deployments with
# long fully-silent tool calls can raise ``HERMES_WEBUI_GATEWAY_READ_TIMEOUT``.
# between SSE lines). We KEEP the 600s default budget: there is no Gateway
# protocol heartbeat guaranteeing sub-600s progress bytes, so a legitimately
# long/fully-silent server-side tool call must not be terminated early — reducing
# the default below 600s would kill currently-working turns (gate finding, #5789).
# The win here is that a read timeout is now TERMINAL and Stop-honoring (the old
# flat timeout ignored Stop on a half-open gateway); the budget itself stays 600s
# for backward compatibility. Deployments that want a tighter dead-gateway cap can
# lower ``HERMES_WEBUI_GATEWAY_READ_TIMEOUT``.
_GATEWAY_READ_TIMEOUT_ENV = "HERMES_WEBUI_GATEWAY_READ_TIMEOUT"
_GATEWAY_READ_TIMEOUT_DEFAULT = 120.0
_GATEWAY_READ_TIMEOUT_DEFAULT = 600.0
def _gateway_read_timeout_secs() -> float:
"""Total byte-silence budget for gateway SSE reads (default 120s, env-tunable)."""
"""Total byte-silence budget for gateway SSE reads (default 600s, env-tunable)."""
raw = os.environ.get(_GATEWAY_READ_TIMEOUT_ENV)
if raw:
try:

View file

@ -8,7 +8,7 @@ bytes) blocked each read for the full 600s, so the worker thread was pinned for
10 minutes and a user's Stop was ignored until then.
The socket now carries a bounded byte-silence budget (``_gateway_read_timeout_secs``,
default 120s, env-tunable) and iteration goes through ``_iter_sse_lines_cancellable``.
default 600s, env-tunable) and iteration goes through ``_iter_sse_lines_cancellable``.
A read timeout is TERMINAL: CPython's ``socket.makefile`` latches
``_timeout_occurred`` on the first ``socket.timeout``, so every subsequent read
raises a bare ``OSError`` and the connection cannot be resumed. The generator
@ -172,10 +172,10 @@ def test_eof_ends_iteration_cleanly():
class TestReadTimeoutConfig:
def test_default_is_120s(self, monkeypatch):
def test_default_is_600s(self, monkeypatch):
monkeypatch.delenv("HERMES_WEBUI_GATEWAY_READ_TIMEOUT", raising=False)
assert _gateway_read_timeout_secs() == 120.0
assert _GATEWAY_READ_TIMEOUT_DEFAULT == 120.0
assert _gateway_read_timeout_secs() == 600.0
assert _GATEWAY_READ_TIMEOUT_DEFAULT == 600.0
def test_env_override(self, monkeypatch):
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_READ_TIMEOUT", "300")
@ -183,12 +183,12 @@ class TestReadTimeoutConfig:
def test_invalid_env_falls_back_to_default(self, monkeypatch):
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_READ_TIMEOUT", "not-a-number")
assert _gateway_read_timeout_secs() == 120.0
assert _gateway_read_timeout_secs() == 600.0
def test_non_positive_env_falls_back_to_default(self, monkeypatch):
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_READ_TIMEOUT", "0")
assert _gateway_read_timeout_secs() == 120.0
assert _gateway_read_timeout_secs() == 600.0
def test_negative_env_falls_back_to_default(self, monkeypatch):
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_READ_TIMEOUT", "-5")
assert _gateway_read_timeout_secs() == 120.0
assert _gateway_read_timeout_secs() == 600.0