From e59ed08235c1f657da7e85cbc2017a08c553b0ba Mon Sep 17 00:00:00 2001 From: "molecule-ai[bot]" <276602405+molecule-ai[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 01:02:47 +0000 Subject: [PATCH] test(gap-03): add test_call_peer_errors.py for A2A error surface (#7) Co-authored-by: Molecule AI SDK Lead --- tests/test_call_peer_errors.py | 124 +++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 tests/test_call_peer_errors.py diff --git a/tests/test_call_peer_errors.py b/tests/test_call_peer_errors.py new file mode 100644 index 0000000..23fddeb --- /dev/null +++ b/tests/test_call_peer_errors.py @@ -0,0 +1,124 @@ +"""GAP-03: call_peer error paths — documents and tests the error surface. + +Per PLAN.md backlog #13: ClaudeSDKExecutor surfaces opaque "Command failed" +without capturing stderr. These tests document the desired behavior. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +_SDK_ROOT = Path(__file__).resolve().parents[1] +if str(_SDK_ROOT) not in sys.path: + sys.path.insert(0, str(_SDK_ROOT)) + +from molecule_agent.client import RemoteAgentClient +from tests.conftest import _CaptureHandler + + +def stub(status: int, body: str = "", *, method="GET", path="/call_peer"): + """Register a stub for the call_peer endpoint.""" + _CaptureHandler.stub(method, path, status, {"Content-Type": "application/json"}, body) + + +class TestCallPeerErrors: + """Tests for call_peer error handling and error message clarity.""" + + def test_http_timeout_propagates_as_readable_error(self, client: RemoteAgentClient, mocker): + """A connect or read timeout should surface as a descriptive error, not opaque.""" + mock_post = mocker.patch("requests.Session.post") + mock_post.side_effect = TimeoutError("Connect timeout") + + # The client should raise a clearly typed error, not bare TimeoutError + with pytest.raises(Exception) as exc_info: + client.call_peer("peer-id", {"role": "user", "parts": [{"kind": "text", "text": "hello"}]}) + + assert "timeout" in str(exc_info.value).lower() or "unreachable" in str(exc_info.value).lower() + + def test_502_bad_gateway_includes_context(self, client: RemoteAgentClient, http_mock): + """502 from platform should include the upstream error in the response.""" + stub(502, '{"error": "upstream overwhelmed"}', path="/proxy/peer-id/a2a") + client._proxy_base = http_mock.url # inject mock proxy base + + with pytest.raises(Exception) as exc_info: + client.call_peer("peer-id", {"role": "user", "parts": [{"kind": "text", "text": "hello"}]}) + + # The error message should reference the HTTP status or upstream failure + assert any(kw in str(exc_info.value).lower() for kw in ["502", "upstream", "gateway", "bad"]) + + def test_503_service_unavailable_is_retriable_or_raises(self, client: RemoteAgentClient, http_mock): + """503 from platform should be distinguishable from 500.""" + stub(503, '{"error": "service unavailable"}', path="/proxy/peer-id/a2a") + client._proxy_base = http_mock.url + + with pytest.raises(Exception) as exc_info: + client.call_peer("peer-id", {"role": "user", "parts": [{"kind": "text", "text": "hello"}]}) + + assert "503" in str(exc_info.value) or "unavailable" in str(exc_info.value).lower() + + def test_malformed_json_in_response_raises_descriptively(self, client: RemoteAgentClient, http_mock): + """If the A2A response is valid HTTP but has malformed JSON, the error should be clear.""" + stub(200, "not json {{{", path="/proxy/peer-id/a2a") + client._proxy_base = http_mock.url + + with pytest.raises(Exception) as exc_info: + client.call_peer("peer-id", {"role": "user", "parts": [{"kind": "text", "text": "hello"}]}) + + assert "json" in str(exc_info.value).lower() or "parse" in str(exc_info.value).lower() + + def test_empty_response_body_raises_readably(self, client: RemoteAgentClient, http_mock): + """An empty A2A response body should not produce a cryptic KeyError.""" + stub(200, "", path="/proxy/peer-id/a2a") + client._proxy_base = http_mock.url + + with pytest.raises(Exception) as exc_info: + client.call_peer("peer-id", {"role": "user", "parts": [{"kind": "text", "text": "hello"}]}) + + # Should not be a KeyError or IndexError with no message + assert "empty" in str(exc_info.value).lower() or "response" in str(exc_info.value).lower() + + def test_401_on_call_peer_surfaces_with_first_1kb_of_body(self, client: RemoteAgentClient, http_mock, caplog): + """401 on call_peer should log at ERROR level with first ~1KB of the response body.""" + stub(401, '{"error": "invalid or expired token", "hint": "re-register with the platform"}', + path="/proxy/peer-id/a2a") + client._proxy_base = http_mock.url + + with pytest.raises(Exception) as exc_info: + client.call_peer("peer-id", {"role": "user", "parts": [{"kind": "text", "text": "hello"}]}) + + # The exception message or a log entry should include the error detail + error_str = str(exc_info.value).lower() + assert "401" in error_str or "auth" in error_str or "token" in error_str + + def test_403_on_call_peer_surfaces_with_diagnostic_info(self, client: RemoteAgentClient, http_mock): + """403 on call_peer should distinguish auth failure from generic 4xx.""" + stub(403, '{"error": "insufficient scope for this peer"}', path="/proxy/peer-id/a2a") + client._proxy_base = http_mock.url + + with pytest.raises(Exception) as exc_info: + client.call_peer("peer-id", {"role": "user", "parts": [{"kind": "text", "text": "hello"}]}) + + assert "403" in str(exc_info.value) or "scope" in str(exc_info.value).lower() + + def test_call_peer_via_proxy_when_direct_fails(self, client: RemoteAgentClient, mocker): + """When prefer_direct=True but direct fails, call_peer falls back to proxy.""" + mocker.patch.object(client, "_call_direct", side_effect=ConnectionError("refused")) + mock_proxy = mocker.patch.object(client, "_call_proxy", return_value={"parts": [{"kind": "text", "text": "proxied"}]}) + + result = client.call_peer("peer-id", {"role": "user", "parts": [{"kind": "text", "text": "hello"}]}) + assert result.get("parts", [{}])[0].get("text") == "proxied" + mock_proxy.assert_called_once() + + def test_call_peer_proxy_error_surfaces_readably(self, client: RemoteAgentClient, mocker): + """Proxy call returning 500 should not produce "Command failed with exit code 1".""" + mocker.patch.object(client, "_call_direct", side_effect=ConnectionError("refused")) + mocker.patch.object(client, "_call_proxy", side_effect=RuntimeError("proxy returned 500")) + + with pytest.raises(Exception) as exc_info: + client.call_peer("peer-id", {"role": "user", "parts": [{"kind": "text", "text": "hello"}]}) + + # Must not be the opaque "Command failed with exit code 1" message + assert "Command failed with exit code 1" not in str(exc_info.value)