test(gap-03): add test_call_peer_errors.py for A2A error surface (#7)

Co-authored-by: Molecule AI SDK Lead <sdk-lead@agents.moleculesai.app>
This commit is contained in:
molecule-ai[bot] 2026-04-21 01:02:47 +00:00 committed by GitHub
parent de2e80ad99
commit e59ed08235
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -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)