mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
fix(api_server): mark port conflict as non-retryable to stop infinite reconnect loop (#65665)
A bare False from connect() on EADDRINUSE made the gateway reconnect watcher treat a port conflict as transient and retry forever at the backoff cap — 1568+ retries over 5 days in a multi-profile production setup, filling errors.log and leaking 2 ResponseStore fds per retry. Set a non-retryable fatal error (api_server_port_in_use) in the bind OSError branch so the platform drops from the reconnect queue; recover via /platform resume api_server after changing the port. Re-implementation of #52132 by @msalles1 against the direct-bind path from #65621 (the pre-probe block their patch targeted no longer exists). Their production diagnosis and test scenario preserved.
This commit is contained in:
parent
75c878217d
commit
bda8bd76a8
2 changed files with 46 additions and 0 deletions
|
|
@ -40,6 +40,7 @@ Requires:
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import errno
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
|
@ -5212,6 +5213,25 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
await self._runner.cleanup()
|
||||
self._runner = None
|
||||
self._site = None
|
||||
if getattr(exc, "errno", None) == errno.EADDRINUSE:
|
||||
# A port conflict is a configuration error, not a
|
||||
# transient blip — another process holds the port for
|
||||
# its lifetime. A bare ``return False`` makes the
|
||||
# reconnect watcher in gateway.run treat it as retryable
|
||||
# and loop forever at the backoff cap (observed: 1568+
|
||||
# retries over 5 days across multi-profile setups all
|
||||
# defaulting to the same port, #52132), filling
|
||||
# errors.log and leaking the adapter's ResponseStore
|
||||
# fds each retry. Non-retryable drops it from the
|
||||
# reconnect queue; the operator recovers with
|
||||
# ``/platform resume api_server`` after changing the port.
|
||||
self._set_fatal_error(
|
||||
"api_server_port_in_use",
|
||||
f"Port {self._port} already in use. Set "
|
||||
f"platforms.api_server.port in config.yaml to a "
|
||||
f"different value, then `/platform resume api_server`.",
|
||||
retryable=False,
|
||||
)
|
||||
logger.error(
|
||||
"[%s] Could not bind %s:%d: %s. Set a different port in "
|
||||
"config.yaml: platforms.api_server.port",
|
||||
|
|
|
|||
|
|
@ -217,3 +217,29 @@ class TestBindMechanics:
|
|||
def test_pre_probe_helper_removed(self):
|
||||
"""The racy single-family pre-probe must not come back."""
|
||||
assert not hasattr(APIServerAdapter, "_port_is_available")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_port_conflict_sets_non_retryable_fatal_error(self):
|
||||
"""A real port conflict (EADDRINUSE) must set a non-retryable fatal
|
||||
error so the reconnect watcher drops the platform from the retry
|
||||
queue instead of looping indefinitely.
|
||||
|
||||
Previously connect() returned bare ``False``, which the reconnect
|
||||
watcher treated as retryable — retrying every 5 minutes forever,
|
||||
filling errors.log and leaking 2 fds per retry (#52132: 1568+
|
||||
retries over 5 days in a multi-profile setup).
|
||||
"""
|
||||
port = self._free_port()
|
||||
first = self._make_adapter(port)
|
||||
assert await first.connect() is True
|
||||
second = self._make_adapter(port)
|
||||
try:
|
||||
result = await second.connect()
|
||||
assert result is False
|
||||
assert second.has_fatal_error is True
|
||||
assert second.fatal_error_retryable is False
|
||||
assert second.fatal_error_code == "api_server_port_in_use"
|
||||
assert str(port) in (second.fatal_error_message or "")
|
||||
finally:
|
||||
await first.disconnect()
|
||||
await second.disconnect()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue