fix(a2a): address reviewer feedback on HTTP transport

- Remove misplaced pytest test files: tests/unit/a2a_test_http_transport.py,
  tests/unit/__init__.py, and features/steps/test_a2a_http_transport_pytest.py.
  Project layout uses Behave in features/ exclusively per CONTRIBUTING.md.
- Resolve AmbiguousStep crash in features/steps/a2a_facade_steps.py by
  deduplicating step_transport_connect / step_transport_disconnect /
  "the transport should not be connected" definitions left over from the
  pre-implementation stub.
- Remove all `# type: ignore[arg-type]` comments (zero-tolerance policy).
- Fix ruff lint failures in src/cleveragents/a2a/transport.py: drop unused
  imports (Any, map_domain_error, BaseHandler, OpenerDirector), wrap long
  log lines (E501), and switch ssl.VerifyMode literal 0 to CERT_NONE for
  pyright compliance.
- Update Robot helpers (robot/helper_a2a_facade.py,
  robot/helper_m6_autonomy_acceptance.py) and the m6 / consolidated Behave
  scenarios to verify the new server-mode lifecycle (connect succeeds with
  valid URL, send-before-connect raises RuntimeError, invalid scheme raises
  ValueError) instead of the obsolete "stub raises A2aNotAvailableError"
  contract.
- Broaden the "I try to connect via the transport to ..." regex so the
  invalid-URL scenario outline matches the empty-string / quoted / None
  example cells; alias "I disconnect the transport" with @then so it is
  reachable from `And` after a `Then` keyword.
This commit is contained in:
2026-06-11 02:35:13 -04:00
committed by Forgejo
parent d71653a056
commit 6390ce1171
12 changed files with 188 additions and 953 deletions
+6 -13
View File
@@ -724,31 +724,24 @@ Feature: Consolidated Misc
When I m6 smoke attempt remote subscribe to "https://example.com/events"
Then the m6 smoke facade should raise A2aNotAvailableError
# --- A2A HTTP transport stub ---
# --- A2A HTTP transport — server-mode ---
Scenario: M6 smoke A2A transport send raises not available
Scenario: M6 smoke A2A transport send while disconnected raises RuntimeError
Given a m6 smoke test runner
And a m6 smoke A2A local facade
When I m6 smoke attempt transport send
Then the m6 smoke facade should raise A2aNotAvailableError
Then the m6 smoke facade should raise RuntimeError
Scenario: M6 smoke A2A transport connect raises not available
Scenario: M6 smoke A2A transport connect succeeds with valid URL
Given a m6 smoke test runner
And a m6 smoke A2A local facade
When I m6 smoke attempt transport connect to "https://example.com/a2a"
Then the m6 smoke facade should raise A2aNotAvailableError
Then the m6 smoke transport should be connected
Scenario: M6 smoke A2A transport disconnect raises not available
Given a m6 smoke test runner
And a m6 smoke A2A local facade
When I m6 smoke attempt transport disconnect
Then the m6 smoke facade should raise A2aNotAvailableError
Scenario: M6 smoke A2A transport is_connected returns false
Scenario: M6 smoke A2A transport is_connected returns false before connect
Given a m6 smoke test runner
And a m6 smoke A2A local facade
When I m6 smoke check transport is_connected
+6 -10
View File
@@ -142,21 +142,17 @@ Feature: M6 autonomy acceptance smoke tests
When I m6 smoke attempt remote subscribe to "https://example.com/events"
Then the m6 smoke facade should raise A2aNotAvailableError
# --- A2A HTTP transport stub ---
# --- A2A HTTP transport — server-mode ---
Scenario: M6 smoke A2A transport send raises not available
Scenario: M6 smoke A2A transport send while disconnected raises RuntimeError
When I m6 smoke attempt transport send
Then the m6 smoke facade should raise A2aNotAvailableError
Then the m6 smoke facade should raise RuntimeError
Scenario: M6 smoke A2A transport connect raises not available
Scenario: M6 smoke A2A transport connect succeeds with valid URL
When I m6 smoke attempt transport connect to "https://example.com/a2a"
Then the m6 smoke facade should raise A2aNotAvailableError
Then the m6 smoke transport should be connected
Scenario: M6 smoke A2A transport disconnect raises not available
When I m6 smoke attempt transport disconnect
Then the m6 smoke facade should raise A2aNotAvailableError
Scenario: M6 smoke A2A transport is_connected returns false
Scenario: M6 smoke A2A transport is_connected returns false before connect
When I m6 smoke check transport is_connected
Then the m6 smoke transport should not be connected
+47 -60
View File
@@ -180,28 +180,6 @@ def step_transport_send(context: Context) -> None:
context.error = exc
@when(r'I try to connect via the transport to "(?P<url>[^"]+)"')
def step_transport_connect(context: Context, url: str) -> None:
context.caught_error = None
context.error = None
try:
context.transport.connect(url)
except A2aNotAvailableError as exc:
context.caught_error = exc
context.error = exc
@when(r"I try to disconnect the transport")
def step_transport_disconnect(context: Context) -> None:
context.caught_error = None
context.error = None
try:
context.transport.disconnect()
except A2aNotAvailableError as exc:
context.caught_error = exc
context.error = exc
@then(r"an A2aNotAvailableError should be raised")
def step_not_available_raised(context: Context) -> None:
assert isinstance(context.caught_error, A2aNotAvailableError), (
@@ -209,11 +187,6 @@ def step_not_available_raised(context: Context) -> None:
)
@then(r"the transport should not be connected")
def step_transport_not_connected(context: Context) -> None:
assert context.transport.is_connected() is False
# A2aEventQueue — local mode
@@ -510,10 +483,9 @@ def step_create_not_available_default(context: Context) -> None:
# "the error message should contain" is provided by service_steps.py
# A2aHttpTransport — server-mode connect / disconnect
@then(r"the transport should be connected when using url")
def step_transport_is_connected(context: Context) -> None:
assert context.transport.is_connected() is True
@@ -526,48 +498,45 @@ def step_transport_unconnected_state(context: Context) -> None:
# ----- connect steps -----
@when(
r"I try to connect via the transport with TLS disabled to (.+)",
)
def step_transport_connect_tls_off(context: Context, url: str) -> None:
@when(r'I try to connect via the transport to "(?P<url>.*)"')
def step_transport_try_connect(context: Context, url: str) -> None:
context.caught_error = None
context.error = None
try:
context.transport.connect(url.strip("'\""), tls_verify=False) # type: ignore[arg-type]
context.transport.connect(url)
except ValueError as exc:
context.caught_error = exc
context.error = exc
@when(r"I connect the transport to (.+)")
@when(r"I connect the transport to (?P<url>.+)")
def step_transport_connect(context: Context, url: str) -> None:
context.transport.connect(url.strip("'\"")) # type: ignore[arg-type]
context.transport.connect(url.strip("'\""))
@when(r"the transport is connected to (.+)")
@when(r"the transport is connected to (?P<url>.+)")
def step_transport_connected(context: Context, url: str) -> None:
context.transport.connect(url.strip("'\"")) # type: ignore[arg-type]
context.transport.connect(url.strip("'\""))
@when(r"I disconnect the transport")
@then(r"I disconnect the transport")
def step_transport_disconnect(context: Context) -> None:
context.transport.disconnect()
# ----- connect value error handling -----
@then(
r"an A2aValueError should be raised with message containing (.+)",
)
@then(r"an A2aValueError should be raised with message containing (?P<reason>.+)")
def step_connect_value_error(context: Context, reason: str) -> None:
assert isinstance(context.caught_error, ValueError), (
f"Expected ValueError, got {type(context.caught_error)}"
)
@then(
r"the ValueError message should contain (.+)",
)
@then(r"the ValueError message should contain (?P<text>.+)")
def step_connect_value_error_message(context: Context, text: str) -> None:
assert context.caught_error is not None
assert isinstance(context.caught_error, ValueError)
@@ -578,9 +547,11 @@ def step_connect_value_error_message(context: Context, text: str) -> None:
# ----- disconnect / unconnected steps -----
@when(r"the transport\s+is in unconnected state")
def step_force_unconnected(context: Context) -> None:
context.transport._is_connected = False # noqa: SLF001
context.transport._is_connected = False
@then(r"the transport should not be connected")
def step_transport_should_not_be_connected(context: Context) -> None:
@@ -589,6 +560,7 @@ def step_transport_should_not_be_connected(context: Context) -> None:
# ----- send non-connected operation (RuntimeError) -----
@when(r"I try to send a request via the unconnected transport")
def step_send_unconnected(context: Context) -> None:
context.caught_error = None
@@ -600,6 +572,7 @@ def step_send_unconnected(context: Context) -> None:
context.caught_error = exc
context.error = exc
@when(r"I dispatch a non-connected operation via the transport")
def step_nonconnected_op(context: Context) -> None:
context.caught_error = None
@@ -611,6 +584,7 @@ def step_nonconnected_op(context: Context) -> None:
context.caught_error = exc
context.error = exc
@then(r"an A2aRuntimeError should be raised for the transport")
def step_runtime_error(context: Context) -> None:
assert isinstance(context.caught_error, RuntimeError), (
@@ -620,16 +594,18 @@ def step_runtime_error(context: Context) -> None:
# ----- send non-A2aRequest (TypeError) -----
@when(r"I try to send a non-A2aRequest via the transport")
def step_send_non_a2arequest(context: Context) -> None:
context.caught_error = None
context.error = None
try:
context.transport.send("not an A2aRequest") # type: ignore[arg-type]
context.transport.send("not an A2aRequest")
except TypeError as exc:
context.caught_error = exc
context.error = exc
@then(r"an A2aTypeError should be raised for the transport")
def step_type_error(context: Context) -> None:
assert isinstance(context.caught_error, TypeError), (
@@ -639,36 +615,47 @@ def step_type_error(context: Context) -> None:
# ----- HTTP response mocking -----
@when(r"HTTP responses are mocked with JSON-RPC success response")
def step_mock_jsonrpc_success(context: Context) -> None:
from unittest.mock import MagicMock
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.read.return_value = json.dumps({
"jsonrpc": "2.0",
"id": "mock-req-id",
"result": {"status": "ok"},
}).encode("utf-8")
mock_resp.read.return_value = json.dumps(
{
"jsonrpc": "2.0",
"id": "mock-req-id",
"result": {"status": "ok"},
}
).encode("utf-8")
context.mock_response = mock_resp
@when(r"HTTP responses are mocked with HTTP 500 status")
def step_mock_http_500(context: Context) -> None:
from unittest.mock import MagicMock
mock_resp = MagicMock()
mock_resp.status = 500
mock_resp.read.return_value = json.dumps({
"jsonrpc": "2.0",
"error": {"code": -32603, "message": "Internal Server Error"},
}).encode("utf-8")
mock_resp.read.return_value = json.dumps(
{
"jsonrpc": "2.0",
"error": {"code": -32603, "message": "Internal Server Error"},
}
).encode("utf-8")
context.mock_response = mock_resp
@when(r"I send a request (.+) via the transport")
@when(r"I send a request (?P<method_str>.+) via the transport")
def step_send_with_mock(context: Context, method_str: str) -> None:
from unittest.mock import patch
if hasattr(context, "mock_response"):
with patch("cleveragents.a2a.transport.urlopen", return_value=context.mock_response):
with patch(
"cleveragents.a2a.transport.urlopen",
return_value=context.mock_response,
):
request = A2aRequest(method="test.op")
context.response = context.transport.send(request)
else:
@@ -678,17 +665,17 @@ def step_send_with_mock(context: Context, method_str: str) -> None:
# ----- response assertions -----
@then(r"the response has no error")
def step_response_no_error(context: Context) -> None:
assert context.response.error is None, (
f"Expected no error, got {context.response.error}"
)
@then(r"the response has an error")
def step_response_has_error(context: Context) -> None:
assert context.response.error is not None, (
"Expected an error in response"
)
assert context.response.error is not None, "Expected an error in response"
# Reset step matcher to parse (default) so subsequent step files are not affected
+19 -13
View File
@@ -287,42 +287,48 @@ def step_m6_smoke_error_not_available(context: Context) -> None:
def step_m6_smoke_transport_send(context: Context) -> None:
transport = A2aHttpTransport()
request = A2aRequest(method="plan.create", params={})
context.m6_transport = transport
try:
transport.send(request)
context.m6_error = None
except A2aNotAvailableError as exc:
except RuntimeError as exc:
context.m6_error = exc
@when('I m6 smoke attempt transport connect to "{url}"')
def step_m6_smoke_transport_connect(context: Context, url: str) -> None:
transport = A2aHttpTransport()
context.m6_transport = transport
try:
transport.connect(url)
context.m6_error = None
except A2aNotAvailableError as exc:
context.m6_error = exc
@when("I m6 smoke attempt transport disconnect")
def step_m6_smoke_transport_disconnect(context: Context) -> None:
transport = A2aHttpTransport()
try:
transport.disconnect()
context.m6_error = None
except A2aNotAvailableError as exc:
except ValueError as exc:
context.m6_error = exc
@when("I m6 smoke check transport is_connected")
def step_m6_smoke_transport_connected(context: Context) -> None:
transport = A2aHttpTransport()
context.m6_transport = transport
context.m6_transport_connected = transport.is_connected()
@then("the m6 smoke facade should raise RuntimeError")
def step_m6_smoke_error_runtime(context: Context) -> None:
assert isinstance(context.m6_error, RuntimeError)
@then("the m6 smoke transport should be connected")
def step_m6_smoke_transport_connected_assert(context: Context) -> None:
assert context.m6_transport.is_connected() is True
@then("the m6 smoke transport should not be connected")
def step_m6_smoke_transport_not_connected(context: Context) -> None:
assert context.m6_transport_connected is False
if hasattr(context, "m6_transport_connected"):
assert context.m6_transport_connected is False
else:
assert context.m6_transport.is_connected() is False
# -----------------------------------------------------------------------
@@ -1,398 +0,0 @@
"""Pytest unit tests for A2aHttpTransport (server-mode HTTP transport)."""
from __future__ import annotations
import json
from http.client import HTTPResponse
from io import BytesIO
from ssl import SSLContext, create_default_context
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from urllib.request import Request
import pytest
from cleveragents.a2a.errors import A2aError, map_domain_error
from cleveragents.a2a.models import A2aErrorDetail, A2aRequest, A2aResponse
from cleveragents.a2a.transport import A2aHttpTransport
if TYPE_CHECKING:
from collections.abc import Iterator
def make_mock_response(status_code: int = 200, body: dict | None = None) -> MagicMock:
"""Create a mocked HTTPResponse for testing."""
mock_resp = MagicMock(spec=HTTPResponse)
mock_resp.status = status_code
if body is not None:
mock_resp.read.return_value = json.dumps(body).encode("utf-8")
else:
mock_resp.read.return_value = b"{}"
return mock_resp
class TestTransportInitialization:
"""Test A2aHttpTransport constructor."""
def test_default_initialization(self) -> None:
transport = A2aHttpTransport()
assert transport._is_connected is False
assert transport._base_url == ""
assert transport._tls_verify is True
assert transport._auth_token_ref is None
def test_custom_tls_verification_false(self) -> None:
transport = A2aHttpTransport(tls_verify=False)
assert transport._tls_verify is False
def test_custom_auth_token(self) -> None:
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test"
transport = A2aHttpTransport(auth_token_ref=token)
assert transport._auth_token_ref == token
class TestConnectValidation:
"""Test connect() method validation."""
def test_connect_empty_string(self) -> None:
transport = A2aHttpTransport()
with pytest.raises(ValueError, match="base_url must be a non-empty string"):
transport.connect("")
def test_connect_none(self) -> None:
transport = A2aHttpTransport()
with pytest.raises(ValueError, match="base_url must be a non-empty string"):
transport.connect(None) # type: ignore[arg-type]
def test_connect_invalid_scheme_http(self) -> None:
transport = A2aHttpTransport()
with pytest.raises(ValueError, match="must start with http:// or https://"):
transport.connect("ftp://localhost")
def test_connect_invalid_scheme_file(self) -> None:
transport = A2aHttpTransport()
with pytest.raises(ValueError, match="must start with http:// or https://"):
transport.connect("file:///some/path")
def test_connect_whitespace_handling(self) -> None:
transport = A2aHttpTransport()
transport.connect(" https://example.com/ ")
assert transport._base_url == "https://example.com/"
def test_trailing_slash_unification(self) -> None:
"""Base URLs should be normalized to have exactly one trailing slash."""
t1 = A2aHttpTransport()
t1.connect("http://example.com")
assert t1._base_url == "http://example.com/"
t2 = A2aHttpTransport()
t2.connect("http://example.com//")
assert t2._base_url == "http://example.com/"
class TestConnectionLifecycle:
"""Test connect -> send -> disconnect lifecycle."""
def test_is_connected_before_connect(self) -> None:
transport = A2aHttpTransport()
assert transport.is_connected() is False
@patch("cleveragents.a2a.transport.create_default_context")
def test_is_connected_after_connect_https(self, mock_ctx) -> None:
mock_ctx.return_value = MagicMock(spec=SSLContext)
transport = A2aHttpTransport()
transport.connect("https://server.example.com/")
assert transport.is_connected() is True
@patch("cleveragents.a2a.transport.create_default_context")
def test_is_connected_after_connect_http(self, mock_ctx) -> None:
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
assert transport.is_connected() is True
@patch("cleveragents.a2a.transport.urlopen")
def test_disconnect_sets_not_connected(self, mock_urlopen) -> None:
transport = A2aHttpTransport()
transport.connect("https://server.example.com/")
assert transport.is_connected() is True
transport.disconnect()
assert transport.is_connected() is False
def test_send_after_disconnect_raises_runtime_error(self) -> None:
transport = A2aHttpTransport()
transport.connect("https://server.example.com/")
transport.disconnect()
request = A2aRequest(method="test.op")
with pytest.raises(RuntimeError, match="Not connected"):
transport.send(request)
class TestSendValidation:
"""Test send() method input validation."""
def test_send_requires_a2a_request(self) -> None:
transport = A2aHttpTransport()
transport.connect("https://server.example.com/")
with pytest.raises(TypeError, match="request must be an A2aRequest"):
transport.send("not a request") # type: ignore[arg-type]
def test_send_before_connect_raises_runtime_error(self) -> None:
transport = A2aHttpTransport()
request = A2aRequest(method="test.op")
with pytest.raises(RuntimeError, match="Not connected"):
transport.send(request)
class TestSslContext:
"""Test _get_ssl_context() behavior."""
def test_http_returns_no_ssl_context(self) -> None:
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
assert transport._get_ssl_context() is None
@patch("cleveragents.a2a.transport.create_default_context")
def test_https_verify_true_creates_default_context(self, mock_create) -> None:
mock_create.return_value = MagicMock(spec=SSLContext)
transport = A2aHttpTransport(tls_verify=True)
transport.connect("https://server.example.com/")
ctx = transport._get_ssl_context()
assert isinstance(ctx, SSLContext)
mock_create.assert_called_once()
@patch("cleveragents.a2a.transport.SSLContext")
def test_https_verify_false_creates_unverified_context(self, mock_ssl_ctx_class) -> None:
mock_mock_ctx = MagicMock(spec=SSLContext)
mock_ssl_ctx_class.return_value = mock_mock_ctx
transport = A2aHttpTransport(tls_verify=False)
transport.connect("https://insecure-server.example.com/")
ctx = transport._get_ssl_context()
assert ctx == mock_mock_ctx
mock_ssl_ctx_class.assert_called_once()
class TestHeaders:
"""Test _build_headers() output."""
def test_default_headers(self) -> None:
transport = A2aHttpTransport()
headers = transport._build_headers()
assert headers["Content-Type"] == "application/json"
assert headers["Accept"] == "application/json"
assert "Authorization" not in headers
def test_headers_with_auth_token(self) -> None:
token = "test-jwt-token-12345"
transport = A2aHttpTransport(auth_token_ref=token)
headers = transport._build_headers()
assert headers["Authorization"] == f"Bearer {token}"
def test_headers_no_auth_when_token_blank(self) -> None:
transport = A2aHttpTransport(auth_token_ref="")
headers = transport._build_headers()
# Empty string is still truthy in the auth check - should not be added since it's a falsy value
assert "Authorization" not in headers
class TestRequestDispatch:
"""Test HTTP request dispatch (using mocked urlopen)."""
@patch("cleveragents.a2a.transport.urlopen")
def test_send_success(self, mock_urlopen) -> None:
"""Successful JSON-RPC response."""
mock_resp = make_mock_response(
status_code=200,
body={"jsonrpc": "2.0", "id": "req-1", "result": {"status": "ok"}},
)
mock_urlopen.return_value = mock_resp
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
request = A2aRequest(method="plan.fetch", params={"plan_id": "01HXM8C2"})
response = transport.send(request)
assert response.error is None
assert response.result == {"status": "ok"}
assert response.id == request.id
@patch("cleveragents.a2a.transport.urlopen")
def test_send_jsonrpc_error(self, mock_urlopen) -> None:
"""Server returns a JSON-RPC error."""
mock_resp = make_mock_response(
status_code=200,
body={
"jsonrpc": "2.0",
"id": "req-1",
"error": {"code": -32001, "message": "Not found"},
},
)
mock_urlopen.return_value = mock_resp
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
request = A2aRequest(method="plan.fetch", params={"plan_id": "notfound"})
response = transport.send(request)
assert response.error is not None
assert response.error.code == -32001
assert response.error.message == "Not found"
@patch("cleveragents.a2a.transport.urlopen")
def test_send_http_500(self, mock_urlopen) -> None:
"""Server returns HTTP 500 Internal Server Error."""
mock_resp = make_mock_response(status_code=500, body={})
mock_urlopen.return_value = mock_resp
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"})
response = transport.send(request)
assert response.error is not None
assert response.error.code == -32603 # INTERNAL_ERROR
assert "500" in response.error.message
@patch("cleveragents.a2a.transport.urlopen")
def test_send_http_404(self, mock_urlopen) -> None:
"""Server returns HTTP 404 Not Found."""
mock_resp = make_mock_response(status_code=404, body={})
mock_urlopen.return_value = mock_resp
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"})
response = transport.send(request)
assert response.error is not None
# 400 <= 404 <= 499 -> error_code = -32000 + (404 - 400) = -31996
assert response.error.code == -31997
assert "404" in response.error.message
@patch("cleveragents.a2a.transport.urlopen")
def test_send_invalid_json_response(self, mock_urlopen) -> None:
"""Server returns non-JSON (e.g., HTML error page)."""
mock_resp = MagicMock(spec=HTTPResponse)
mock_resp.status = 200
mock_resp.read.return_value = b"<!DOCTYPE html>Error</html>"
mock_urlopen.return_value = mock_resp
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"})
with pytest.raises(A2aError, match="Invalid JSON"):
transport.send(request)
@patch("cleveragents.a2a.transport.urlopen")
def test_send_wrong_jsonrpc_version(self, mock_urlopen) -> None:
"""Server returns a response with incorrect jsonrpc version."""
mock_resp = make_mock_response(
status_code=200,
body={"jsonrpc": "1.0", "id": "req-1"},
)
mock_urlopen.return_value = mock_resp
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"})
with pytest.raises(A2aError, match="Expected JSON-RPC 2.0"):
transport.send(request)
@patch("cleveragents.a2a.transport.urlopen")
def test_send_embedded_jsonrpc_error_on_http_status(self, mock_urlopen) -> None:
"""HTTP error with embedded JSON-RPC error takes the embedded codes."""
mock_resp = make_mock_response(
status_code=503,
body={
"jsonrpc": "2.0",
"error": {"code": -32700, "message": "Service unavailable"},
},
)
mock_urlopen.return_value = mock_resp
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"})
response = transport.send(request)
assert response.error is not None
# Embedded error takes precedence: -32700 (JSON-RPC parse error code)
assert response.error.code == -32700
assert "Service unavailable" in response.error.message
@patch("cleveragents.a2a.transport.urlopen")
def test_send_with_auth_token(self, mock_urlopen) -> None:
"""Auth token is included as Bearer header."""
mock_resp = make_mock_response(
status_code=200,
body={"jsonrpc": "2.0", "id": "req-1", "result": {"ok": True}},
)
mock_urlopen.return_value = mock_resp
token = "secret-jwt"
transport = A2aHttpTransport(auth_token_ref=token)
transport.connect("https://secure.example.com/")
request = A2aRequest(method="plan.fetch", params={})
transport.send(request)
call_args = mock_urlopen.call_args
req: Request = call_args[0][0] # First positional arg
assert req.headers["Authorization"] == f"Bearer {token}"
@patch("cleveragents.a2a.transport.urlopen")
def test_send_url_correct(self, mock_urlopen) -> None:
"""Requests go to the correct URL path."""
mock_resp = make_mock_response(status_code=200, body={})
mock_urlopen.return_value = mock_resp
transport = A2aHttpTransport()
transport.connect("https://api.example.com/a2a/")
request = A2aRequest(method="test.op", params={})
transport.send(request)
call_args = mock_urlopen.call_args
req: Request = call_args[0][0]
assert req.get_full_url() == "https://api.example.com/a2a/_rpc"
class TestNetworkErrors:
"""Test network-level error handling."""
@patch("cleveragents.a2a.transport.urlopen")
def test_network_timeout(self, mock_urlopen) -> None:
"""URLError raises wraps in A2aError with clear message."""
from urllib.error import URLError
mock_urlopen.side_effect = URLError("Connection timed out")
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
request = A2aRequest(method="plan.fetch", params={})
with pytest.raises(A2aError, match="Network error"):
transport.send(request)
class TestIntegration:
"""Integration: roundtrip serialization/deserialization."""
@patch("cleveragents.a2a.transport.urlopen")
def test_roundtrip(self, mock_urlopen) -> None:
"""Request params are preserved in the response."""
response_body = {
"jsonrpc": "2.0",
"id": "ulid-123",
"result": {"plan_id": "plan-abc", "status": "running"},
}
mock_resp = make_mock_response(status_code=200, body=response_body)
mock_urlopen.return_value = mock_resp
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
request = A2aRequest(method="plan.status", params={"plan_id": "plan-abc"})
response = transport.send(request)
assert response.error is None
assert response.result["plan_id"] == "plan-abc"
assert response.result["status"] == "running"
+2 -2
View File
@@ -17,8 +17,8 @@ A2A Local Facade Dispatch Session Create
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} a2a-facade-dispatch-ok
A2A HTTP Transport Stub Error
[Documentation] Verify HTTP transport raises A2aNotAvailableError
A2A HTTP Transport Lifecycle
[Documentation] Verify server-mode HTTP transport connect/send/disconnect lifecycle
[Tags] tdd_issue tdd_issue_4190
${result}= Run Process ${PYTHON} ${HELPER} transport-stub cwd=${WORKSPACE}
Log ${result.stdout}
+33 -13
View File
@@ -13,10 +13,7 @@ _SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from cleveragents.a2a.errors import ( # noqa: E402
A2aNotAvailableError,
A2aVersionMismatchError,
)
from cleveragents.a2a.errors import A2aVersionMismatchError # noqa: E402
from cleveragents.a2a.events import A2aEventQueue # noqa: E402
from cleveragents.a2a.facade import A2aLocalFacade # noqa: E402
from cleveragents.a2a.models import A2aEvent, A2aRequest # noqa: E402
@@ -45,21 +42,44 @@ def facade_dispatch() -> None:
def transport_stub() -> None:
"""Verify transport stub raises A2aNotAvailableError."""
"""Verify server-mode HTTP transport connect/disconnect lifecycle."""
transport = A2aHttpTransport()
try:
transport.connect("http://localhost:8080")
print("FAIL: should have raised", file=sys.stderr)
if transport.is_connected() is not False:
print("FAIL: is_connected should be False before connect", file=sys.stderr)
sys.exit(1)
except A2aNotAvailableError:
# Send before connect should raise RuntimeError
try:
transport.send(A2aRequest(method="plan.create", params={}))
print("FAIL: expected RuntimeError on send-before-connect", file=sys.stderr)
sys.exit(1)
except RuntimeError:
pass
if transport.is_connected() is False:
print("a2a-transport-stub-ok")
else:
print("FAIL: is_connected should be False", file=sys.stderr)
# Connect with valid URL should succeed
transport.connect("http://localhost:8080")
if transport.is_connected() is not True:
print("FAIL: is_connected should be True after connect", file=sys.stderr)
sys.exit(1)
# Invalid URL should raise ValueError
transport_bad = A2aHttpTransport()
try:
transport_bad.connect("ftp://localhost")
print("FAIL: expected ValueError on invalid scheme", file=sys.stderr)
sys.exit(1)
except ValueError:
pass
# Disconnect should clear state
transport.disconnect()
if transport.is_connected() is not False:
print("FAIL: is_connected should be False after disconnect", file=sys.stderr)
sys.exit(1)
print("a2a-transport-stub-ok")
def event_queue() -> None:
"""Verify local event queue publish/subscribe."""
+18 -18
View File
@@ -15,7 +15,6 @@ if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from cleveragents.a2a.errors import ( # noqa: E402
A2aNotAvailableError,
A2aOperationNotFoundError,
A2aVersionMismatchError,
)
@@ -134,34 +133,35 @@ def event_queue() -> None:
def transport_stub() -> None:
"""Verify HTTP transport stub raises A2aNotAvailableError."""
"""Verify server-mode HTTP transport lifecycle and error semantics."""
transport = A2aHttpTransport()
# send
# is_connected before connect should be False
assert transport.is_connected() is False
# send before connect should raise RuntimeError
try:
transport.send(A2aRequest(method="plan.create", params={}))
print("FAIL: expected A2aNotAvailableError on send")
print("FAIL: expected RuntimeError on send-before-connect")
sys.exit(1)
except A2aNotAvailableError:
except RuntimeError:
pass
# connect
# connect with valid URL succeeds
transport.connect("https://example.com/a2a")
assert transport.is_connected() is True
# connect with invalid scheme raises ValueError
transport_bad = A2aHttpTransport()
try:
transport.connect("https://example.com/a2a")
print("FAIL: expected A2aNotAvailableError on connect")
transport_bad.connect("ftp://localhost")
print("FAIL: expected ValueError on invalid scheme")
sys.exit(1)
except A2aNotAvailableError:
except ValueError:
pass
# disconnect
try:
transport.disconnect()
print("FAIL: expected A2aNotAvailableError on disconnect")
sys.exit(1)
except A2aNotAvailableError:
pass
# is_connected
# disconnect clears state
transport.disconnect()
assert transport.is_connected() is False
print("m6-transport-stub-ok")
+2 -2
View File
@@ -40,8 +40,8 @@ M6 A2A Event Queue Publish Subscribe
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} m6-event-queue-ok
M6 A2A Transport Stub Rejects All
[Documentation] Verify HTTP transport stub raises A2aNotAvailableError
M6 A2A Transport Lifecycle
[Documentation] Verify server-mode HTTP transport connect/send/disconnect lifecycle
${result}= Run Process ${PYTHON} ${HELPER} transport-stub cwd=${WORKSPACE} timeout=120s on_timeout=kill
Log ${result.stdout}
Log ${result.stderr}
+55 -26
View File
@@ -4,33 +4,30 @@ from __future__ import annotations
import json
from http.client import HTTPResponse
from ssl import SSLContext, create_default_context
from typing import TYPE_CHECKING, Any
from ssl import CERT_NONE, SSLContext, create_default_context
from urllib.error import URLError
from urllib.request import Request, urlopen
import structlog
from cleveragents.a2a.errors import (
A2aError,
map_domain_error,
)
from cleveragents.a2a.errors import A2aError
from cleveragents.a2a.models import (
A2aErrorDetail,
A2aRequest,
A2aResponse,
)
if TYPE_CHECKING:
from urllib.request import BaseHandler, OpenerDirector
logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)
class A2aHttpTransport:
"""HTTP transport for server-mode A2A communication."""
def __init__(self, tls_verify: bool = True, auth_token_ref: str | None = None) -> None:
def __init__(
self,
tls_verify: bool = True,
auth_token_ref: str | None = None,
) -> None:
self._base_url: str = ""
self._tls_verify: bool = tls_verify
self._is_connected: bool = False
@@ -45,9 +42,17 @@ class A2aHttpTransport:
self._base_url = stripped.rstrip("/") + "/"
self._is_connected = True
if stripped.startswith("https://"):
logger.info("a2a.http.connect", base_url=self._base_url, tls_verify=self._tls_verify)
logger.info(
"a2a.http.connect",
base_url=self._base_url,
tls_verify=self._tls_verify,
)
else:
logger.warning("a2a.http.connect", base_url=self._base_url, warning="connecting over plain HTTP")
logger.warning(
"a2a.http.connect",
base_url=self._base_url,
warning="connecting over plain HTTP",
)
def disconnect(self) -> None:
self._is_connected = False
@@ -75,7 +80,11 @@ class A2aHttpTransport:
raise
except URLError as exc:
reason = str(exc.reason) if hasattr(exc, "reason") else str(exc)
logger.error("a2a.http.send_url_error", method=request.method, reason=reason)
logger.error(
"a2a.http.send_url_error",
method=request.method,
reason=reason,
)
raise A2aError(f"Network error connecting to server: {exc}") from exc
except Exception as exc:
logger.error("a2a.http.send_error", method=request.method, error=str(exc))
@@ -86,32 +95,44 @@ class A2aHttpTransport:
return None
if self._tls_verify:
return create_default_context()
else:
context = SSLContext()
context.check_hostname = False
context.verify_mode = 0
return context
context = SSLContext()
context.check_hostname = False
context.verify_mode = CERT_NONE
return context
def _build_headers(self) -> dict[str, str]:
headers: dict[str, str] = {
"Content-Type": "application/json",
"Accept": "application/json",
}
if self._auth_token_ref is not None:
if self._auth_token_ref:
headers["Authorization"] = f"Bearer {self._auth_token_ref}"
return headers
def _do_request(self, payload: bytes, request: A2aRequest) -> HTTPResponse:
url = self._base_url + "_rpc"
req = Request(url, data=payload, headers=self._build_headers(), method="POST")
req = Request(
url,
data=payload,
headers=self._build_headers(),
method="POST",
)
ssl_ctx = self._get_ssl_context()
http_response = urlopen(req, context=ssl_ctx if ssl_ctx else None, timeout=30)
return http_response
return urlopen(req, context=ssl_ctx if ssl_ctx else None, timeout=30)
def _parse_response(self, resp: HTTPResponse, request: A2aRequest) -> A2aResponse:
def _parse_response(
self,
resp: HTTPResponse,
request: A2aRequest,
) -> A2aResponse:
status_code = resp.status
body_str = resp.read().decode("utf-8")
logger.debug("a2a.http.receive", method=request.method, request_id=request.id, status=status_code)
logger.debug(
"a2a.http.receive",
method=request.method,
request_id=request.id,
status=status_code,
)
if status_code >= 400:
return self._handle_http_status(body_str, request, status_code)
@@ -130,7 +151,12 @@ class A2aHttpTransport:
except Exception as exc:
raise A2aError(f"Failed to parse response: {exc}") from exc
def _handle_http_status(self, body_str: str, request: A2aRequest, status_code: int) -> A2aResponse:
def _handle_http_status(
self,
body_str: str,
request: A2aRequest,
status_code: int,
) -> A2aResponse:
if 400 <= status_code <= 499:
error_code = -32000 + (status_code - 400)
else:
@@ -141,7 +167,10 @@ class A2aHttpTransport:
response_dict = json.loads(body_str)
if "jsonrpc" in response_dict and "error" in response_dict:
error_code = response_dict["error"].get("code", error_code)
error_message = response_dict["error"].get("message", f"HTTP {status_code}")
error_message = response_dict["error"].get(
"message",
f"HTTP {status_code}",
)
except json.JSONDecodeError:
pass
View File
-398
View File
@@ -1,398 +0,0 @@
"""Pytest unit tests for A2aHttpTransport (server-mode HTTP transport)."""
from __future__ import annotations
import json
from http.client import HTTPResponse
from io import BytesIO
from ssl import SSLContext, create_default_context
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from urllib.request import Request
import pytest
from cleveragents.a2a.errors import A2aError, map_domain_error
from cleveragents.a2a.models import A2aErrorDetail, A2aRequest, A2aResponse
from cleveragents.a2a.transport import A2aHttpTransport
if TYPE_CHECKING:
from collections.abc import Iterator
def make_mock_response(status_code: int = 200, body: dict | None = None) -> MagicMock:
"""Create a mocked HTTPResponse for testing."""
mock_resp = MagicMock(spec=HTTPResponse)
mock_resp.status = status_code
if body is not None:
mock_resp.read.return_value = json.dumps(body).encode("utf-8")
else:
mock_resp.read.return_value = b"{}"
return mock_resp
class TestTransportInitialization:
"""Test A2aHttpTransport constructor."""
def test_default_initialization(self) -> None:
transport = A2aHttpTransport()
assert transport._is_connected is False
assert transport._base_url == ""
assert transport._tls_verify is True
assert transport._auth_token_ref is None
def test_custom_tls_verification_false(self) -> None:
transport = A2aHttpTransport(tls_verify=False)
assert transport._tls_verify is False
def test_custom_auth_token(self) -> None:
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test"
transport = A2aHttpTransport(auth_token_ref=token)
assert transport._auth_token_ref == token
class TestConnectValidation:
"""Test connect() method validation."""
def test_connect_empty_string(self) -> None:
transport = A2aHttpTransport()
with pytest.raises(ValueError, match="base_url must be a non-empty string"):
transport.connect("")
def test_connect_none(self) -> None:
transport = A2aHttpTransport()
with pytest.raises(ValueError, match="base_url must be a non-empty string"):
transport.connect(None) # type: ignore[arg-type]
def test_connect_invalid_scheme_http(self) -> None:
transport = A2aHttpTransport()
with pytest.raises(ValueError, match="must start with http:// or https://"):
transport.connect("ftp://localhost")
def test_connect_invalid_scheme_file(self) -> None:
transport = A2aHttpTransport()
with pytest.raises(ValueError, match="must start with http:// or https://"):
transport.connect("file:///some/path")
def test_connect_whitespace_handling(self) -> None:
transport = A2aHttpTransport()
transport.connect(" https://example.com/ ")
assert transport._base_url == "https://example.com/"
def test_trailing_slash_unification(self) -> None:
"""Base URLs should be normalized to have exactly one trailing slash."""
t1 = A2aHttpTransport()
t1.connect("http://example.com")
assert t1._base_url == "http://example.com/"
t2 = A2aHttpTransport()
t2.connect("http://example.com//")
assert t2._base_url == "http://example.com/"
class TestConnectionLifecycle:
"""Test connect -> send -> disconnect lifecycle."""
def test_is_connected_before_connect(self) -> None:
transport = A2aHttpTransport()
assert transport.is_connected() is False
@patch("cleveragents.a2a.transport.create_default_context")
def test_is_connected_after_connect_https(self, mock_ctx) -> None:
mock_ctx.return_value = MagicMock(spec=SSLContext)
transport = A2aHttpTransport()
transport.connect("https://server.example.com/")
assert transport.is_connected() is True
@patch("cleveragents.a2a.transport.create_default_context")
def test_is_connected_after_connect_http(self, mock_ctx) -> None:
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
assert transport.is_connected() is True
@patch("cleveragents.a2a.transport.urlopen")
def test_disconnect_sets_not_connected(self, mock_urlopen) -> None:
transport = A2aHttpTransport()
transport.connect("https://server.example.com/")
assert transport.is_connected() is True
transport.disconnect()
assert transport.is_connected() is False
def test_send_after_disconnect_raises_runtime_error(self) -> None:
transport = A2aHttpTransport()
transport.connect("https://server.example.com/")
transport.disconnect()
request = A2aRequest(method="test.op")
with pytest.raises(RuntimeError, match="Not connected"):
transport.send(request)
class TestSendValidation:
"""Test send() method input validation."""
def test_send_requires_a2a_request(self) -> None:
transport = A2aHttpTransport()
transport.connect("https://server.example.com/")
with pytest.raises(TypeError, match="request must be an A2aRequest"):
transport.send("not a request") # type: ignore[arg-type]
def test_send_before_connect_raises_runtime_error(self) -> None:
transport = A2aHttpTransport()
request = A2aRequest(method="test.op")
with pytest.raises(RuntimeError, match="Not connected"):
transport.send(request)
class TestSslContext:
"""Test _get_ssl_context() behavior."""
def test_http_returns_no_ssl_context(self) -> None:
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
assert transport._get_ssl_context() is None
@patch("cleveragents.a2a.transport.create_default_context")
def test_https_verify_true_creates_default_context(self, mock_create) -> None:
mock_create.return_value = MagicMock(spec=SSLContext)
transport = A2aHttpTransport(tls_verify=True)
transport.connect("https://server.example.com/")
ctx = transport._get_ssl_context()
assert isinstance(ctx, SSLContext)
mock_create.assert_called_once()
@patch("cleveragents.a2a.transport.SSLContext")
def test_https_verify_false_creates_unverified_context(self, mock_ssl_ctx_class) -> None:
mock_mock_ctx = MagicMock(spec=SSLContext)
mock_ssl_ctx_class.return_value = mock_mock_ctx
transport = A2aHttpTransport(tls_verify=False)
transport.connect("https://insecure-server.example.com/")
ctx = transport._get_ssl_context()
assert ctx == mock_mock_ctx
mock_ssl_ctx_class.assert_called_once()
class TestHeaders:
"""Test _build_headers() output."""
def test_default_headers(self) -> None:
transport = A2aHttpTransport()
headers = transport._build_headers()
assert headers["Content-Type"] == "application/json"
assert headers["Accept"] == "application/json"
assert "Authorization" not in headers
def test_headers_with_auth_token(self) -> None:
token = "test-jwt-token-12345"
transport = A2aHttpTransport(auth_token_ref=token)
headers = transport._build_headers()
assert headers["Authorization"] == f"Bearer {token}"
def test_headers_no_auth_when_token_blank(self) -> None:
transport = A2aHttpTransport(auth_token_ref="")
headers = transport._build_headers()
# Empty string is still truthy in the auth check - should not be added since it's a falsy value
assert "Authorization" not in headers
class TestRequestDispatch:
"""Test HTTP request dispatch (using mocked urlopen)."""
@patch("cleveragents.a2a.transport.urlopen")
def test_send_success(self, mock_urlopen) -> None:
"""Successful JSON-RPC response."""
mock_resp = make_mock_response(
status_code=200,
body={"jsonrpc": "2.0", "id": "req-1", "result": {"status": "ok"}},
)
mock_urlopen.return_value = mock_resp
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
request = A2aRequest(method="plan.fetch", params={"plan_id": "01HXM8C2"})
response = transport.send(request)
assert response.error is None
assert response.result == {"status": "ok"}
assert response.id == request.id
@patch("cleveragents.a2a.transport.urlopen")
def test_send_jsonrpc_error(self, mock_urlopen) -> None:
"""Server returns a JSON-RPC error."""
mock_resp = make_mock_response(
status_code=200,
body={
"jsonrpc": "2.0",
"id": "req-1",
"error": {"code": -32001, "message": "Not found"},
},
)
mock_urlopen.return_value = mock_resp
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
request = A2aRequest(method="plan.fetch", params={"plan_id": "notfound"})
response = transport.send(request)
assert response.error is not None
assert response.error.code == -32001
assert response.error.message == "Not found"
@patch("cleveragents.a2a.transport.urlopen")
def test_send_http_500(self, mock_urlopen) -> None:
"""Server returns HTTP 500 Internal Server Error."""
mock_resp = make_mock_response(status_code=500, body={})
mock_urlopen.return_value = mock_resp
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"})
response = transport.send(request)
assert response.error is not None
assert response.error.code == -32603 # INTERNAL_ERROR
assert "500" in response.error.message
@patch("cleveragents.a2a.transport.urlopen")
def test_send_http_404(self, mock_urlopen) -> None:
"""Server returns HTTP 404 Not Found."""
mock_resp = make_mock_response(status_code=404, body={})
mock_urlopen.return_value = mock_resp
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"})
response = transport.send(request)
assert response.error is not None
# 400 <= 404 <= 499 -> error_code = -32000 + (404 - 400) = -31996
assert response.error.code == -31997
assert "404" in response.error.message
@patch("cleveragents.a2a.transport.urlopen")
def test_send_invalid_json_response(self, mock_urlopen) -> None:
"""Server returns non-JSON (e.g., HTML error page)."""
mock_resp = MagicMock(spec=HTTPResponse)
mock_resp.status = 200
mock_resp.read.return_value = b"<!DOCTYPE html>Error</html>"
mock_urlopen.return_value = mock_resp
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"})
with pytest.raises(A2aError, match="Invalid JSON"):
transport.send(request)
@patch("cleveragents.a2a.transport.urlopen")
def test_send_wrong_jsonrpc_version(self, mock_urlopen) -> None:
"""Server returns a response with incorrect jsonrpc version."""
mock_resp = make_mock_response(
status_code=200,
body={"jsonrpc": "1.0", "id": "req-1"},
)
mock_urlopen.return_value = mock_resp
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"})
with pytest.raises(A2aError, match="Expected JSON-RPC 2.0"):
transport.send(request)
@patch("cleveragents.a2a.transport.urlopen")
def test_send_embedded_jsonrpc_error_on_http_status(self, mock_urlopen) -> None:
"""HTTP error with embedded JSON-RPC error takes the embedded codes."""
mock_resp = make_mock_response(
status_code=503,
body={
"jsonrpc": "2.0",
"error": {"code": -32700, "message": "Service unavailable"},
},
)
mock_urlopen.return_value = mock_resp
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
request = A2aRequest(method="plan.fetch", params={"plan_id": "p1"})
response = transport.send(request)
assert response.error is not None
# Embedded error takes precedence: -32700 (JSON-RPC parse error code)
assert response.error.code == -32700
assert "Service unavailable" in response.error.message
@patch("cleveragents.a2a.transport.urlopen")
def test_send_with_auth_token(self, mock_urlopen) -> None:
"""Auth token is included as Bearer header."""
mock_resp = make_mock_response(
status_code=200,
body={"jsonrpc": "2.0", "id": "req-1", "result": {"ok": True}},
)
mock_urlopen.return_value = mock_resp
token = "secret-jwt"
transport = A2aHttpTransport(auth_token_ref=token)
transport.connect("https://secure.example.com/")
request = A2aRequest(method="plan.fetch", params={})
transport.send(request)
call_args = mock_urlopen.call_args
req: Request = call_args[0][0] # First positional arg
assert req.headers["Authorization"] == f"Bearer {token}"
@patch("cleveragents.a2a.transport.urlopen")
def test_send_url_correct(self, mock_urlopen) -> None:
"""Requests go to the correct URL path."""
mock_resp = make_mock_response(status_code=200, body={})
mock_urlopen.return_value = mock_resp
transport = A2aHttpTransport()
transport.connect("https://api.example.com/a2a/")
request = A2aRequest(method="test.op", params={})
transport.send(request)
call_args = mock_urlopen.call_args
req: Request = call_args[0][0]
assert req.get_full_url() == "https://api.example.com/a2a/_rpc"
class TestNetworkErrors:
"""Test network-level error handling."""
@patch("cleveragents.a2a.transport.urlopen")
def test_network_timeout(self, mock_urlopen) -> None:
"""URLError raises wraps in A2aError with clear message."""
from urllib.error import URLError
mock_urlopen.side_effect = URLError("Connection timed out")
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
request = A2aRequest(method="plan.fetch", params={})
with pytest.raises(A2aError, match="Network error"):
transport.send(request)
class TestIntegration:
"""Integration: roundtrip serialization/deserialization."""
@patch("cleveragents.a2a.transport.urlopen")
def test_roundtrip(self, mock_urlopen) -> None:
"""Request params are preserved in the response."""
response_body = {
"jsonrpc": "2.0",
"id": "ulid-123",
"result": {"plan_id": "plan-abc", "status": "running"},
}
mock_resp = make_mock_response(status_code=200, body=response_body)
mock_urlopen.return_value = mock_resp
transport = A2aHttpTransport()
transport.connect("http://server.example.com/")
request = A2aRequest(method="plan.status", params={"plan_id": "plan-abc"})
response = transport.send(request)
assert response.error is None
assert response.result["plan_id"] == "plan-abc"
assert response.result["status"] == "running"