"""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