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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user