Files
hermes-agent/tests/hermes_cli/test_peer_dm_reporting.py
John Paul Soliva 0497b94570 fix(peer): a connect-phase timeout is unreachable; only a response timeout means the turn was accepted
urllib wraps any error raised while connecting or sending the request in
URLError (AbstractHTTPHandler.do_open) and lets a timeout on the response
through bare. The accepted-turn branch also matched URLError(reason=
TimeoutError), so a peer that went down between the Bot Chat lookup and the
chat POST was reported as "already in its Bot Chat ... Do NOT resend" while
the message had never arrived.

Match only the bare TimeoutError. A second test drives both phases through
the real opener, so the signatures the branch depends on are pinned rather
than assumed; the HTTP-rejection case folds into the table as a row.

Reported by Enough1122 in review.
2026-09-20 10:02:22 -07:00

113 lines
4.8 KiB
Python

"""What ``hermes peer dm`` tells the sender when the turn does not come back.
A peer DM is one synchronous ``POST /api/sessions/{id}/chat`` with a 600 s client timeout, and the
receiving gateway runs the turn to completion whatever the client does. So a timeout while awaiting
the response means the message IS in the peer's Bot Chat and is being answered — the opposite of
unreachable. Reporting it as unreachable makes the sending bot resend, and the teammate runs the same
turn twice. A timeout while connecting means the request never arrived, and must stay unreachable.
"""
from __future__ import annotations
import contextlib
import socket
import threading
import urllib.error
from types import SimpleNamespace
import pytest
from hermes_cli.subcommands import peer as peer_mod
SESSION = "20260916_bot_chat"
def _run(monkeypatch, capsys, *, base="http://192.168.2.55:8642", raise_on_post=None, raise_on_session=None):
def _ensure(base, key):
if raise_on_session is not None:
raise raise_on_session
return SESSION
monkeypatch.setattr(peer_mod, "_ensure_bot_chat", _ensure)
if raise_on_post is not None:
def _request(url, key, **kwargs):
raise raise_on_post
monkeypatch.setattr(peer_mod, "_request", _request)
code = peer_mod._peer_dm(SimpleNamespace(json=False), "hello", "mini", None, base, "key")
return code, capsys.readouterr().err
@pytest.mark.parametrize(
("raise_on_post", "raise_on_session", "expected", "forbidden"),
[
(TimeoutError("timed out"), None, "accepted the message but its turn is still running", "Could not reach"),
(urllib.error.URLError(TimeoutError("timed out")), None, "Could not reach peer", "still running"),
(urllib.error.URLError(ConnectionRefusedError(61, "Connection refused")), None, "Could not reach peer", "still running"),
(OSError(51, "Network is unreachable"), None, "Could not reach peer", "still running"),
(TimeoutError("timed out"), TimeoutError("timed out"), "Could not reach peer", "still running"),
(urllib.error.HTTPError("http://peer/chat", 503, "Service Unavailable", {}, None), None,
"rejected the request (HTTP 503)", "still running"),
],
ids=["response-timeout", "connect-timeout", "connection-refused", "network-unreachable",
"timeout-before-the-session-is-known", "http-rejection"],
)
def test_only_a_timeout_on_an_accepted_turn_is_reported_as_delivered(
monkeypatch, capsys, raise_on_post, raise_on_session, expected, forbidden
):
"""Two things must both hold to call the message delivered: the Bot Chat was already resolved
(the peer answered a moment ago) and the timeout hit while awaiting the response (urllib raises
that one bare). A connect-phase timeout, a refusal, or a status code proves nothing arrived."""
code, err = _run(monkeypatch, capsys, raise_on_post=raise_on_post, raise_on_session=raise_on_session)
assert code == 1
assert expected in err
assert forbidden not in err
if "still running" in expected:
assert SESSION in err
assert "Do NOT resend" in err
@pytest.mark.parametrize("phase", ["response", "connect"])
def test_the_real_urllib_stack_raises_the_signatures_the_branch_relies_on(monkeypatch, capsys, phase):
"""Through the real opener, not a hand-built exception: urllib wraps a connect/send failure in
``URLError`` and lets a timeout on the response through bare. Getting that backwards reports a
message that never arrived as delivered, and the sender is told not to resend it."""
for var in ("http_proxy", "HTTP_PROXY", "all_proxy", "ALL_PROXY"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setattr(peer_mod, "DM_TIMEOUT_S", 0.5)
server = socket.socket()
server.bind(("127.0.0.1", 0))
server.listen(1)
held = []
done = threading.Event()
def _accept_and_never_answer():
with contextlib.suppress(OSError): # the connect phase never dials, so close() ends accept()
conn, _ = server.accept()
held.append(conn)
conn.recv(65536)
done.wait(10)
thread = threading.Thread(target=_accept_and_never_answer, daemon=True)
thread.start()
if phase == "connect":
def _connect_times_out(*args, **kwargs):
raise TimeoutError("timed out")
monkeypatch.setattr(socket, "create_connection", _connect_times_out)
try:
code, err = _run(monkeypatch, capsys, base=f"http://127.0.0.1:{server.getsockname()[1]}")
finally:
done.set()
server.close()
for conn in held:
conn.close()
assert code == 1
if phase == "response":
assert "accepted the message but its turn is still running" in err and "Do NOT resend" in err
else:
assert "Could not reach peer" in err and "still running" not in err