125 lines
6.3 KiB
Python
125 lines
6.3 KiB
Python
"""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)
|