Skip to content

Commit 2eb7dd9

Browse files
jpalvarezlCopilot
andcommitted
Python: Preserve caller-owned A2A HTTP clients
Leave HTTP clients supplied alongside an A2A protocol client open on agent exit. Extend the existing ownership regression, retain internally owned cleanup coverage, and document caller cleanup responsibility. Co-authored-by: Copilot <[email protected]>
1 parent ecf822b commit 2eb7dd9

3 files changed

Lines changed: 15 additions & 25 deletions

File tree

‎python/packages/a2a/README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,17 @@ response = await a2a_agent.run("Hello!")
2424

2525
**Breaking change:** HTTP clients created by `A2AAgent` no longer persist response cookies. This prevents
2626
one run's cookies from being reused by another run through the same agent.
27-
Connection pooling and the client's existing lifetime are unchanged.
27+
Connection pooling and cleanup of internally created clients are unchanged.
2828

2929
If the remote service requires cookies for authentication, sessions, or load-balancer affinity, explicitly supply an
3030
`httpx.AsyncClient` using `http_client=`. Its cookie behavior is preserved.
3131
Scope that client to the intended authenticated user or security context and
3232
manage its lifetime; do not share a user-specific cookie jar across users.
3333

34+
Supplied HTTP clients remain caller-owned even when `client=` is also provided.
35+
Applications that previously relied on the agent closing a supplied HTTP client
36+
in that combination must now close it explicitly.
37+
3438
### A2AExecutor (Hosting)
3539

3640
The `A2AExecutor` class bridges local AI agents built with the `agent_framework` library to the A2A protocol, allowing them to be hosted and accessed by other A2A-compliant clients.

‎python/packages/a2a/agent_framework_a2a/_agent.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,8 @@ def __init__(
246246
url: The URL for the A2A server.
247247
client: The A2A client for the agent.
248248
http_client: Optional httpx.AsyncClient to use. Agent-created clients do not persist
249-
response cookies; supplied clients retain their configured cookie behavior.
249+
response cookies; supplied clients retain their configured cookie behavior and
250+
remain caller-owned, including when ``client`` is also supplied.
250251
auth_interceptor: Optional authentication interceptor for secured endpoints.
251252
timeout: Request timeout configuration. Can be a float (applied to all timeout components),
252253
httpx.Timeout object (for full control), or None (uses 10.0s connect, 60.0s read,
@@ -265,15 +266,13 @@ def __init__(
265266

266267
super().__init__(id=id, name=name, description=description, **kwargs)
267268
self._http_client: httpx.AsyncClient | None = http_client
268-
# By default, leave a caller-supplied HTTP client open; specific paths may override this below.
269-
# every construction path must set this before __aexit__ can run
269+
# Only HTTP clients created by this agent are closed on exit.
270270
self._close_http_client = False
271271
self._timeout_config = self._create_timeout_config(timeout)
272272
bindings = supported_protocol_bindings if supported_protocol_bindings is not None else ["JSONRPC"]
273273
if client is not None:
274274
self.client = client
275275
self._non_streaming_client: Client | None = None
276-
self._close_http_client = True
277276
return
278277
if agent_card is None:
279278
if url is None:

‎python/packages/a2a/tests/test_a2a_agent.py‎

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -501,24 +501,6 @@ async def test_run_streaming_with_message_response(a2a_agent: A2AAgent, mock_a2a
501501
assert mock_a2a_client.call_count == 1
502502

503503

504-
async def test_context_manager_cleanup() -> None:
505-
"""Test context manager cleanup of http client."""
506-
507-
# Create mock http client that tracks aclose calls
508-
mock_http_client = AsyncMock()
509-
mock_a2a_client = MagicMock()
510-
511-
agent = A2AAgent(client=cast(Any, mock_a2a_client))
512-
agent._http_client = mock_http_client
513-
514-
# Test context manager cleanup
515-
async with agent:
516-
pass
517-
518-
# Verify aclose was called
519-
mock_http_client.aclose.assert_called_once()
520-
521-
522504
async def test_context_manager_no_cleanup_when_no_http_client() -> None:
523505
"""Test context manager when _http_client is None."""
524506

@@ -531,7 +513,8 @@ async def test_context_manager_no_cleanup_when_no_http_client() -> None:
531513
pass
532514

533515

534-
async def test_context_manager_does_not_close_caller_supplied_http_client() -> None:
516+
@mark.parametrize("supply_a2a_client", [False, True])
517+
async def test_context_manager_does_not_close_caller_supplied_http_client(supply_a2a_client: bool) -> None:
535518
"""A caller-supplied HTTP client must stay open and retain its cookie behavior after __aexit__."""
536519

537520
def handle_request(request: httpx.Request) -> httpx.Response:
@@ -541,7 +524,11 @@ def handle_request(request: httpx.Request) -> httpx.Response:
541524
async with httpx.AsyncClient(
542525
transport=httpx.MockTransport(handle_request), cookies={"caller_cookie": "kept"}
543526
) as http_client:
544-
async with A2AAgent(url="https://a2a.example.test/", http_client=http_client):
527+
async with A2AAgent(
528+
url="https://a2a.example.test/",
529+
client=MagicMock() if supply_a2a_client else None,
530+
http_client=http_client,
531+
):
545532
pass
546533

547534
assert not http_client.is_closed

0 commit comments

Comments
 (0)