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.
113 lines
4.8 KiB
Python
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
|