From 24a082d004678fc5895ba19f9aadce03e1da57b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Ra=C4=8Dinsk=C3=BD?= Date: Thu, 22 Oct 2026 10:15:41 +0200 Subject: [PATCH 1/2] chore: migration to httpx2 --- .github/actions/spelling/allow.txt | 1 - .gitignore | 1 + itk/acts_client_parse.py | 16 +-- itk/main.py | 6 +- itk/pyproject.toml | 1 - pyproject.toml | 3 +- samples/agent_card_signing.py | 12 +- samples/cli.py | 6 +- samples/clustermode/exercise.py | 10 +- src/a2a/client/card_resolver.py | 12 +- src/a2a/client/client.py | 4 +- src/a2a/client/client_factory.py | 8 +- src/a2a/client/transports/http_helpers.py | 38 +++--- src/a2a/client/transports/jsonrpc.py | 4 +- src/a2a/client/transports/rest.py | 10 +- src/a2a/compat/v0_3/jsonrpc_transport.py | 6 +- src/a2a/compat/v0_3/rest_transport.py | 21 +-- .../tasks/base_push_notification_sender.py | 4 +- tck/sut_agent.py | 8 +- tests/client/test_auth_interceptor.py | 36 ++++-- tests/client/test_card_resolver.py | 32 ++--- tests/client/test_client_factory.py | 22 ++-- tests/client/transports/test_http_helpers.py | 10 +- .../client/transports/test_jsonrpc_client.py | 34 ++--- tests/client/transports/test_rest_client.py | 74 +++++------ tests/compat/v0_3/test_jsonrpc_transport.py | 14 +- tests/compat/v0_3/test_rest_routes_compat.py | 2 +- tests/compat/v0_3/test_rest_transport.py | 58 ++++----- .../cross_version/client_server/client_0_3.py | 6 +- .../cross_version/client_server/client_1_0.py | 6 +- .../push_notifications/agent_app.py | 6 +- .../test_default_push_notification_support.py | 26 ++-- tests/integration/push_notifications/utils.py | 6 +- tests/integration/test_agent_card.py | 6 +- .../test_client_server_integration.py | 18 +-- .../integration/test_copying_observability.py | 6 +- tests/integration/test_end_to_end.py | 10 +- tests/integration/test_samples_smoke.py | 6 +- .../test_stream_generator_cleanup.py | 6 +- tests/integration/test_tenant.py | 10 +- .../test_default_request_handler_v2.py | 4 +- .../tasks/test_inmemory_push_notifications.py | 22 ++-- .../tasks/test_push_notification_sender.py | 26 ++-- tests/utils/test_proto_utils.py | 4 +- uv.lock | 120 ++++++++++++------ 45 files changed, 396 insertions(+), 345 deletions(-) diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 0bd580314..104605948 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -135,7 +135,6 @@ pyproto pyupgrade pyversions redef -respx resub rmi RS256 diff --git a/.gitignore b/.gitignore index 3172bc117..601997ace 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ spec.json docker-compose.yaml .geminiignore docs/ai/ai_learnings.md +.idea # ITK Integration Test Artifacts itk/a2a-itk/ diff --git a/itk/acts_client_parse.py b/itk/acts_client_parse.py index 92b294d9a..f5f50c314 100644 --- a/itk/acts_client_parse.py +++ b/itk/acts_client_parse.py @@ -33,7 +33,7 @@ from typing import Any -import httpx +import httpx2 from google.protobuf.json_format import MessageToDict @@ -70,7 +70,7 @@ def _is_enveloped(payload: Any) -> bool: ) -def _transport(payload: Any) -> httpx.MockTransport: +def _transport(payload: Any) -> httpx2.MockTransport: """An HTTP transport that answers every request with ``payload``. The response's JSON-RPC ``id`` is rewritten to echo the request's, which @@ -82,7 +82,7 @@ def _transport(payload: Any) -> httpx.MockTransport: express. """ - def handler(request: httpx.Request) -> httpx.Response: + def handler(request: httpx2.Request) -> httpx2.Response: body = payload if _is_enveloped(payload): try: @@ -91,9 +91,9 @@ def handler(request: httpx.Request) -> httpx.Response: sent = None if isinstance(sent, dict) and 'id' in sent: body = {**payload, 'id': sent['id']} - return httpx.Response(200, json=body) + return httpx2.Response(200, json=body) - return httpx.MockTransport(handler) + return httpx2.MockTransport(handler) def _scaffold_card() -> AgentCard: @@ -129,7 +129,7 @@ async def _client(payload: Any) -> Any: config = ClientConfig() config.streaming = False config.supported_protocol_bindings = [TransportProtocol.JSONRPC] - config.httpx_client = httpx.AsyncClient(transport=_transport(payload)) + config.httpx_client = httpx2.AsyncClient(transport=_transport(payload)) return await create_client(_scaffold_card(), client_config=config) @@ -141,7 +141,7 @@ async def _parse_card(payload: Any, path: str) -> dict[str, Any]: plain HTTP on every binding, so there is no envelope to unwrap. The two differ only in the path they are served from. """ - async with httpx.AsyncClient(transport=_transport(payload)) as http: + async with httpx2.AsyncClient(transport=_transport(payload)) as http: resolver = A2ACardResolver(httpx_client=http, base_url=_BASE_URL) card = await resolver.get_agent_card(relative_card_path=path) return MessageToDict(card) @@ -149,7 +149,7 @@ async def _parse_card(payload: Any, path: str) -> dict[str, Any]: async def _parse_extended_card_envelope(payload: Any) -> dict[str, Any]: """The enveloped form of `get_extended_agent_card`, via the RPC client.""" - async with httpx.AsyncClient(transport=_transport(payload)) as http: + async with httpx2.AsyncClient(transport=_transport(payload)) as http: config = ClientConfig() config.streaming = False config.supported_protocol_bindings = [TransportProtocol.JSONRPC] diff --git a/itk/main.py b/itk/main.py index 6b762ee3d..4cb69b69e 100644 --- a/itk/main.py +++ b/itk/main.py @@ -7,7 +7,7 @@ import uuid import grpc -import httpx +import httpx2 import uvicorn from fastapi import FastAPI @@ -300,7 +300,7 @@ async def handle_call_agent( token='itk-token', # noqa: S106 ) - async with httpx.AsyncClient(timeout=30.0) as httpx_client: + async with httpx2.AsyncClient(timeout=30.0) as httpx_client: config.httpx_client = httpx_client try: client = await create_client( @@ -738,7 +738,7 @@ async def main_async(http_port: int, grpc_port: int) -> None: task_store = InMemoryTaskStore() push_config_store = InMemoryPushNotificationConfigStore() - httpx_client = httpx.AsyncClient() + httpx_client = httpx2.AsyncClient() push_sender = BasePushNotificationSender( httpx_client=httpx_client, config_store=push_config_store, diff --git a/itk/pyproject.toml b/itk/pyproject.toml index f8dfa8e7d..572280ffb 100644 --- a/itk/pyproject.toml +++ b/itk/pyproject.toml @@ -10,7 +10,6 @@ dependencies = [ "grpcio-tools", "protobuf", "sse-starlette", - "httpx-sse", "packaging", ] diff --git a/pyproject.toml b/pyproject.toml index 23590e6a2..7157de78c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ authors = [{ name = "Google LLC", email = "googleapis-packages@google.com" }] requires-python = ">=3.10" keywords = ["A2A", "A2A SDK", "A2A Protocol", "Agent2Agent", "Agent 2 Agent"] dependencies = [ - "httpx>=0.28.1", + "httpx2>=2.14.0", "pydantic>=2.11.3", "protobuf>=5.29.5", "google-api-core>=1.26.0", @@ -113,7 +113,6 @@ dev = [ "pytest-cov>=6.1.1", "pytest-mock>=3.14.0", "pytest-xdist>=3.6.1", - "respx>=0.20.2", # Test-only oracle. The RFC 8785 conformance tests check the canonicalizer # against an independent implementation, so a mistake shared between the # implementation and its tests still shows up. Not a runtime dependency. diff --git a/samples/agent_card_signing.py b/samples/agent_card_signing.py index 499a80b4c..abda2b6bd 100644 --- a/samples/agent_card_signing.py +++ b/samples/agent_card_signing.py @@ -52,7 +52,7 @@ from collections.abc import Callable, Mapping from typing import Any -import httpx +import httpx2 import uvicorn from cryptography.hazmat.primitives.asymmetric import ec @@ -275,7 +275,7 @@ def __init__(self, jwks_by_url: Mapping[str, PyJWKSet]) -> None: @classmethod async def fetch( - cls, trusted_jku_urls: set[str], http_client: httpx.AsyncClient + cls, trusted_jku_urls: set[str], http_client: httpx2.AsyncClient ) -> 'JwksKeyProvider': """Fetches every trusted JWKS and builds a provider from them. @@ -324,7 +324,7 @@ def __call__(self, kid: str | None, jku: str | None) -> PyJWK: async def create_card_verifier( - trusted_jku_urls: set[str], http_client: httpx.AsyncClient + trusted_jku_urls: set[str], http_client: httpx2.AsyncClient ) -> Callable[[AgentCard], None]: """Builds a verifier that accepts cards signed by the trusted keys. @@ -356,7 +356,7 @@ async def verify_remote_card(base_url: str) -> AgentCard: SignatureVerificationError: If the card carries no signature, or none of its signatures can be verified. """ - async with httpx.AsyncClient() as http_client: + async with httpx2.AsyncClient() as http_client: verifier = await create_card_verifier( trusted_jku_urls={f'{base_url}{JWKS_WELL_KNOWN_PATH}'}, http_client=http_client, @@ -373,7 +373,7 @@ async def verify_remote_card(base_url: str) -> AgentCard: async def _fetch_card_json( - base_url: str, http_client: httpx.AsyncClient + base_url: str, http_client: httpx2.AsyncClient ) -> dict[str, Any]: """Fetches the raw Agent Card JSON, bypassing verification.""" response = await http_client.get(f'{base_url}{AGENT_CARD_WELL_KNOWN_PATH}') @@ -384,7 +384,7 @@ async def _fetch_card_json( async def run_demo(base_url: str) -> None: """Verifies a good card, then two cards a verifier should reject.""" - async with httpx.AsyncClient() as http_client: + async with httpx2.AsyncClient() as http_client: verifier = await create_card_verifier( trusted_jku_urls={f'{base_url}{JWKS_WELL_KNOWN_PATH}'}, http_client=http_client, diff --git a/samples/cli.py b/samples/cli.py index beff26aa9..5f361d0e4 100644 --- a/samples/cli.py +++ b/samples/cli.py @@ -7,7 +7,7 @@ from typing import Any import grpc -import httpx +import httpx2 from a2a.client import A2ACardResolver, ClientConfig, create_client from a2a.helpers import get_artifact_text, get_message_text @@ -83,7 +83,7 @@ async def main() -> None: f'Connecting to {args.url} (preferred transport: {args.transport or "Any"})' ) - async with httpx.AsyncClient() as httpx_client: + async with httpx2.AsyncClient() as httpx_client: resolver = A2ACardResolver(httpx_client, args.url) card = await resolver.get_agent_card() print('\n✓ Agent Card Found:') @@ -124,7 +124,7 @@ async def main() -> None: try: stream = client.send_message(request) current_task_id = await _handle_stream(stream, current_task_id) - except (httpx.RequestError, grpc.RpcError) as e: + except (httpx2.RequestError, grpc.RpcError) as e: print(f'Error communicating with agent: {e}') await client.close() diff --git a/samples/clustermode/exercise.py b/samples/clustermode/exercise.py index 210a25414..c85af17f5 100644 --- a/samples/clustermode/exercise.py +++ b/samples/clustermode/exercise.py @@ -18,7 +18,7 @@ from typing import Any -import httpx +import httpx2 from google.protobuf.json_format import MessageToDict, ParseDict from google.protobuf.message import Message as ProtoMessage @@ -46,7 +46,7 @@ def _endpoint(host: str, port: int) -> str: async def _rpc( - client: httpx.AsyncClient, + client: httpx2.AsyncClient, url: str, method: str, params_msg: ProtoMessage, @@ -69,7 +69,7 @@ async def _rpc( return data['result'] -async def _get_task(client: httpx.AsyncClient, url: str, task_id: str) -> Task: +async def _get_task(client: httpx2.AsyncClient, url: str, task_id: str) -> Task: """Fetches a task via GetTask from the given replica.""" result = await _rpc(client, url, 'GetTask', GetTaskRequest(id=task_id)) task = Task() @@ -78,7 +78,7 @@ async def _get_task(client: httpx.AsyncClient, url: str, task_id: str) -> Task: async def _start_streaming( - client: httpx.AsyncClient, url: str, send: SendMessageRequest + client: httpx2.AsyncClient, url: str, send: SendMessageRequest ) -> tuple[str, asyncio.Task[None]]: """Starts a streaming send; returns (task_id, drainer task). @@ -128,7 +128,7 @@ async def run(host: str, ports: list[int]) -> None: urls = [_endpoint(host, p) for p in ports] - async with httpx.AsyncClient() as client: + async with httpx2.AsyncClient() as client: # 1) Start a streaming task on replica 0. The stream stays open (agent # runs) while we observe and cancel from other replicas. print(f'[send] -> replica 0 ({urls[0]}) (streaming)') diff --git a/src/a2a/client/card_resolver.py b/src/a2a/client/card_resolver.py index 447913572..01a6c0f15 100644 --- a/src/a2a/client/card_resolver.py +++ b/src/a2a/client/card_resolver.py @@ -4,7 +4,7 @@ from collections.abc import Callable from typing import Any -import httpx +import httpx2 from google.protobuf.json_format import ParseDict, ParseError @@ -133,14 +133,14 @@ class A2ACardResolver: def __init__( self, - httpx_client: httpx.AsyncClient, + httpx_client: httpx2.AsyncClient, base_url: str, agent_card_path: str = AGENT_CARD_WELL_KNOWN_PATH, ) -> None: """Initializes the A2ACardResolver. Args: - httpx_client: An async HTTP client instance (e.g., httpx.AsyncClient). + httpx_client: An async HTTP client instance (e.g., httpx2.AsyncClient). base_url: The base URL of the agent's host. agent_card_path: The path to the agent card endpoint, relative to the base URL. """ @@ -164,7 +164,7 @@ async def get_agent_card( relative to the base URL. If None, uses the default public agent card path. Use `'/'` for an empty path. http_kwargs: Optional dictionary of keyword arguments to pass to the - underlying httpx.get request. + underlying httpx2.get request. signature_verifier: A callable used to verify the agent card's signatures. Returns: @@ -200,7 +200,7 @@ async def get_agent_card( agent_card = parse_agent_card(agent_card_data) if signature_verifier: signature_verifier(agent_card) - except httpx.HTTPStatusError as e: + except httpx2.HTTPStatusError as e: raise AgentCardResolutionError( f'Failed to fetch agent card from {target_url} (HTTP {e.response.status_code}): {e}', status_code=e.response.status_code, @@ -209,7 +209,7 @@ async def get_agent_card( raise AgentCardResolutionError( f'Failed to parse JSON for agent card from {target_url}: {e}' ) from e - except httpx.RequestError as e: + except httpx2.RequestError as e: raise AgentCardResolutionError( f'Network communication error fetching agent card from {target_url}: {e}', ) from e diff --git a/src/a2a/client/client.py b/src/a2a/client/client.py index 3fbf4f287..aa817e88c 100644 --- a/src/a2a/client/client.py +++ b/src/a2a/client/client.py @@ -6,7 +6,7 @@ from types import TracebackType from typing import Any -import httpx +import httpx2 from pydantic import BaseModel, Field from typing_extensions import Self @@ -48,7 +48,7 @@ class ClientConfig: the callers job to check if the response is completed and if not run a polling loop.""" - httpx_client: httpx.AsyncClient | None = None + httpx_client: httpx2.AsyncClient | None = None """Http client to use to connect to agent.""" grpc_channel_factory: Callable[[str], Channel] | None = None diff --git a/src/a2a/client/client_factory.py b/src/a2a/client/client_factory.py index db6266779..5c9766015 100644 --- a/src/a2a/client/client_factory.py +++ b/src/a2a/client/client_factory.py @@ -5,7 +5,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any -import httpx +import httpx2 from packaging.version import InvalidVersion, Version @@ -78,7 +78,7 @@ class ClientFactory: The client can be used consistently regardless of the transport. This aligns the client configuration with the server's capabilities. - Each created client gets its own `httpx.AsyncClient` and closes it on + Each created client gets its own `httpx2.AsyncClient` and closes it on `close()`, unless `ClientConfig.httpx_client` is set. """ @@ -96,11 +96,11 @@ def __init__( self._registry: dict[str, TransportProducer] = {} self._register_defaults(config.supported_protocol_bindings) - def _resolve_http_client(self) -> tuple[httpx.AsyncClient, bool]: + def _resolve_http_client(self) -> tuple[httpx2.AsyncClient, bool]: """Returns an HTTP client and whether it is shared across clients.""" if self._config.httpx_client: return self._config.httpx_client, True - httpx_client = httpx.AsyncClient() + httpx_client = httpx2.AsyncClient() httpx_client.headers.setdefault( VERSION_HEADER, PROTOCOL_VERSION_CURRENT ) diff --git a/src/a2a/client/transports/http_helpers.py b/src/a2a/client/transports/http_helpers.py index b0d601321..9b2235e79 100644 --- a/src/a2a/client/transports/http_helpers.py +++ b/src/a2a/client/transports/http_helpers.py @@ -4,7 +4,7 @@ from contextlib import contextmanager from typing import Any, NoReturn -import httpx +import httpx2 from a2a.client.client import ClientCallContext from a2a.client.errors import A2AClientError, A2AClientTimeoutError @@ -16,25 +16,25 @@ def _default_sse_error_handler(sse_data: str) -> NoReturn: @contextmanager def handle_http_exceptions( - status_error_handler: Callable[[httpx.HTTPStatusError], NoReturn] + status_error_handler: Callable[[httpx2.HTTPStatusError], NoReturn] | None = None, ) -> Iterator[None]: """Handles common HTTP exceptions for REST and JSON-RPC transports. Args: - status_error_handler: Optional handler for `httpx.HTTPStatusError`. + status_error_handler: Optional handler for `httpx2.HTTPStatusError`. If provided, this handler should raise an appropriate domain-specific exception. If not provided, a default `A2AClientError` will be raised. """ try: yield - except httpx.TimeoutException as e: + except httpx2.TimeoutException as e: raise A2AClientTimeoutError('Client Request timed out') from e - except httpx.HTTPStatusError as e: + except httpx2.HTTPStatusError as e: if status_error_handler: status_error_handler(e) raise A2AClientError(f'HTTP Error {e.response.status_code}: {e}') from e - except httpx.RequestError as e: + except httpx2.RequestError as e: raise A2AClientError(f'Network communication error: {e}') from e except json.JSONDecodeError as e: raise A2AClientError(f'JSON Decode Error: {e}') from e @@ -46,14 +46,14 @@ def get_http_args(context: ClientCallContext | None) -> dict[str, Any]: if context and context.service_parameters: http_kwargs['headers'] = context.service_parameters.copy() if context and context.timeout is not None: - http_kwargs['timeout'] = httpx.Timeout(context.timeout) + http_kwargs['timeout'] = httpx2.Timeout(context.timeout) return http_kwargs async def send_http_request( - httpx_client: httpx.AsyncClient, - request: httpx.Request, - status_error_handler: Callable[[httpx.HTTPStatusError], NoReturn] + httpx_client: httpx2.AsyncClient, + request: httpx2.Request, + status_error_handler: Callable[[httpx2.HTTPStatusError], NoReturn] | None = None, ) -> dict[str, Any]: """Sends an HTTP request and parses the JSON response, handling common exceptions.""" @@ -66,9 +66,9 @@ async def send_http_request( async def parse_sse_stream( - response: httpx.Response, + response: httpx2.Response, ) -> AsyncGenerator[tuple[str, str], None]: - """Yields (event_name, data) from a streaming httpx.Response. + """Yields (event_name, data) from a streaming httpx2.Response. Conforms to the W3C Server-Sent Events specification. """ @@ -106,10 +106,10 @@ async def parse_sse_stream( async def send_http_stream_request( - httpx_client: httpx.AsyncClient, + httpx_client: httpx2.AsyncClient, method: str, url: str, - status_error_handler: Callable[[httpx.HTTPStatusError], NoReturn] + status_error_handler: Callable[[httpx2.HTTPStatusError], NoReturn] | None = None, sse_error_handler: Callable[[str], NoReturn] = _default_sse_error_handler, **kwargs: Any, @@ -135,7 +135,7 @@ async def send_http_stream_request( ) as response: try: response.raise_for_status() - except httpx.HTTPStatusError as e: + except httpx2.HTTPStatusError as e: # Read upfront streaming error content immediately, otherwise lower-level handlers # (e.g. response.json()) crash with 'ResponseNotRead' Access errors. await response.aread() @@ -174,21 +174,21 @@ class _SSEEventSource: def __init__( self, - client: httpx.AsyncClient, + client: httpx2.AsyncClient, method: str, url: str, **kwargs: Any, ) -> None: - headers = httpx.Headers(kwargs.pop('headers', None)) + headers = httpx2.Headers(kwargs.pop('headers', None)) headers.setdefault('Accept', 'text/event-stream') headers.setdefault('Cache-Control', 'no-store') self._request = client.build_request( method, url, headers=headers, **kwargs ) self._client = client - self._response: httpx.Response | None = None + self._response: httpx2.Response | None = None - async def __aenter__(self) -> httpx.Response: + async def __aenter__(self) -> httpx2.Response: self._response = await self._client.send(self._request, stream=True) return self._response diff --git a/src/a2a/client/transports/jsonrpc.py b/src/a2a/client/transports/jsonrpc.py index 01fef1507..d1b812d8d 100644 --- a/src/a2a/client/transports/jsonrpc.py +++ b/src/a2a/client/transports/jsonrpc.py @@ -4,7 +4,7 @@ from typing import Any, NoReturn from uuid import uuid4 -import httpx +import httpx2 from google.protobuf import json_format from jsonrpc.jsonrpc2 import JSONRPC20Request, JSONRPC20Response @@ -54,7 +54,7 @@ class JsonRpcTransport(ClientTransport): def __init__( self, - httpx_client: httpx.AsyncClient, + httpx_client: httpx2.AsyncClient, agent_card: AgentCard, url: str, is_shared_httpx: bool = False, diff --git a/src/a2a/client/transports/rest.py b/src/a2a/client/transports/rest.py index ba1f4c098..8bd46acd6 100644 --- a/src/a2a/client/transports/rest.py +++ b/src/a2a/client/transports/rest.py @@ -4,7 +4,7 @@ from collections.abc import AsyncGenerator from typing import Any, NoReturn -import httpx +import httpx2 from google.protobuf.json_format import MessageToDict @@ -95,7 +95,7 @@ class RestTransport(ClientTransport): def __init__( self, - httpx_client: httpx.AsyncClient, + httpx_client: httpx2.AsyncClient, agent_card: AgentCard, url: str, is_shared_httpx: bool = False, @@ -351,7 +351,7 @@ def _get_path(self, base_path: str, tenant: str) -> str: """Returns the full path, prepending the tenant if provided.""" return f'/{tenant}{base_path}' if tenant else base_path - def _handle_http_error(self, e: httpx.HTTPStatusError) -> NoReturn: + def _handle_http_error(self, e: httpx2.HTTPStatusError) -> NoReturn: """Handles HTTP status errors and raises the appropriate A2AError.""" try: error_payload = e.response.json() @@ -362,7 +362,7 @@ def _handle_http_error(self, e: httpx.HTTPStatusError) -> NoReturn: pass status_code = e.response.status_code - if status_code == httpx.codes.NOT_FOUND: + if status_code == httpx2.codes.NOT_FOUND: raise MethodNotFoundError( f'Resource not found: {e.request.url}' ) from e @@ -401,7 +401,7 @@ async def _send_stream_request( event: StreamResponse = parse_lenient(sse_data, StreamResponse()) yield event - async def _send_request(self, request: httpx.Request) -> dict[str, Any]: + async def _send_request(self, request: httpx2.Request) -> dict[str, Any]: return await send_http_request( self.httpx_client, request, self._handle_http_error ) diff --git a/src/a2a/compat/v0_3/jsonrpc_transport.py b/src/a2a/compat/v0_3/jsonrpc_transport.py index 9e83168a1..d13a9b860 100644 --- a/src/a2a/compat/v0_3/jsonrpc_transport.py +++ b/src/a2a/compat/v0_3/jsonrpc_transport.py @@ -5,7 +5,7 @@ from typing import Any, NoReturn from uuid import uuid4 -import httpx +import httpx2 from jsonrpc.jsonrpc2 import JSONRPC20Request, JSONRPC20Response @@ -56,7 +56,7 @@ class CompatJsonRpcTransport(ClientTransport): def __init__( self, - httpx_client: httpx.AsyncClient, + httpx_client: httpx2.AsyncClient, agent_card: AgentCard | None, url: str, *, @@ -424,7 +424,7 @@ def _create_jsonrpc_error(self, error_dict: dict[str, Any]) -> Exception: return A2AClientError(message) - def _handle_http_error(self, e: httpx.HTTPStatusError) -> NoReturn: + def _handle_http_error(self, e: httpx2.HTTPStatusError) -> NoReturn: """Handles HTTP errors for standard requests.""" raise A2AClientError(f'HTTP Error: {e.response.status_code}') from e diff --git a/src/a2a/compat/v0_3/rest_transport.py b/src/a2a/compat/v0_3/rest_transport.py index 362946736..820e89bf6 100644 --- a/src/a2a/compat/v0_3/rest_transport.py +++ b/src/a2a/compat/v0_3/rest_transport.py @@ -5,7 +5,7 @@ from collections.abc import AsyncGenerator from typing import Any, NoReturn -import httpx +import httpx2 from google.protobuf.json_format import MessageToDict, Parse, ParseDict @@ -62,7 +62,7 @@ class CompatRestTransport(ClientTransport): def __init__( self, - httpx_client: httpx.AsyncClient, + httpx_client: httpx2.AsyncClient, agent_card: AgentCard | None, url: str, subscribe_method_override: str | None = None, @@ -299,11 +299,12 @@ async def subscribe( ): yield event except A2AClientError as e: - # Check for 405 Method Not Allowed in the cause (httpx.HTTPStatusError) + # Check for 405 Method Not Allowed in the cause (httpx2.HTTPStatusError) cause = e.__cause__ if ( - isinstance(cause, httpx.HTTPStatusError) - and cause.response.status_code == httpx.codes.METHOD_NOT_ALLOWED + isinstance(cause, httpx2.HTTPStatusError) + and cause.response.status_code + == httpx2.codes.METHOD_NOT_ALLOWED ): if self._subscribe_method_override: if self._subscribe_auto_method_override: @@ -355,15 +356,15 @@ async def close(self) -> None: except Exception as e: raise A2AClientError(f'Failed to close HTTP client: {e}') from e - def _handle_http_error(self, e: httpx.HTTPStatusError) -> NoReturn: + def _handle_http_error(self, e: httpx2.HTTPStatusError) -> NoReturn: """Handles HTTP status errors and raises the appropriate A2AError.""" try: - with contextlib.suppress(httpx.StreamClosed): + with contextlib.suppress(httpx2.StreamClosed): e.response.read() try: error_data = e.response.json() - except (json.JSONDecodeError, ValueError, httpx.ResponseNotRead): + except (json.JSONDecodeError, ValueError, httpx2.ResponseNotRead): error_data = {} if not isinstance(error_data, dict): error_data = {} @@ -379,7 +380,7 @@ def _handle_http_error(self, e: httpx.HTTPStatusError) -> NoReturn: pass status_code = e.response.status_code - if status_code == httpx.codes.NOT_FOUND: + if status_code == httpx2.codes.NOT_FOUND: raise MethodNotFoundError( f'Resource not found: {e.request.url}' ) from e @@ -415,7 +416,7 @@ async def _send_stream_request( ) ) - async def _send_request(self, request: httpx.Request) -> dict[str, Any]: + async def _send_request(self, request: httpx2.Request) -> dict[str, Any]: return await send_http_request( self.httpx_client, request, self._handle_http_error ) diff --git a/src/a2a/server/tasks/base_push_notification_sender.py b/src/a2a/server/tasks/base_push_notification_sender.py index 070361a52..8c664e88a 100644 --- a/src/a2a/server/tasks/base_push_notification_sender.py +++ b/src/a2a/server/tasks/base_push_notification_sender.py @@ -3,7 +3,7 @@ from collections.abc import Awaitable, Callable -import httpx +import httpx2 from google.protobuf.json_format import MessageToDict @@ -27,7 +27,7 @@ class BasePushNotificationSender(PushNotificationSender): def __init__( self, - httpx_client: httpx.AsyncClient, + httpx_client: httpx2.AsyncClient, config_store: PushNotificationConfigStore, context: ServerCallContext | None = None, *, diff --git a/tck/sut_agent.py b/tck/sut_agent.py index 206774c64..631f48fa1 100644 --- a/tck/sut_agent.py +++ b/tck/sut_agent.py @@ -3,7 +3,7 @@ import os import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime import grpc.aio import uvicorn @@ -71,7 +71,7 @@ async def cancel( context_id=context.context_id or str(uuid.uuid4()), status=TaskStatus( state=TaskState.TASK_STATE_CANCELED, - timestamp=datetime.now(timezone.utc), + timestamp=datetime.now(UTC), ), ) await event_queue.enqueue_event(status_update) @@ -114,7 +114,7 @@ async def execute( task_id=task_id, context_id=context_id, ), - timestamp=datetime.now(timezone.utc), + timestamp=datetime.now(UTC), ), ) await event_queue.enqueue_event(working_status) @@ -142,7 +142,7 @@ async def execute( status=TaskStatus( state=TaskState.TASK_STATE_INPUT_REQUIRED, message=agent_message, - timestamp=datetime.now(timezone.utc), + timestamp=datetime.now(UTC), ), ) await event_queue.enqueue_event(final_update) diff --git a/tests/client/test_auth_interceptor.py b/tests/client/test_auth_interceptor.py index f7979bff7..58d03d93a 100644 --- a/tests/client/test_auth_interceptor.py +++ b/tests/client/test_auth_interceptor.py @@ -5,9 +5,8 @@ from dataclasses import dataclass from typing import Any -import httpx +import httpx2 import pytest -import respx from a2a.client import ( AuthInterceptor, @@ -40,7 +39,7 @@ from google.protobuf import json_format -def build_success_response(request: httpx.Request) -> httpx.Response: +def build_success_response(request: httpx2.Request) -> httpx2.Response: """Creates a valid JSON-RPC success response based on the request.""" request_payload = json.loads(request.content) @@ -55,7 +54,7 @@ def build_success_response(request: httpx.Request) -> httpx.Response: 'jsonrpc': '2.0', 'result': json_format.MessageToDict(response), } - return httpx.Response(200, json=response_payload) + return httpx2.Response(200, json=response_payload) def build_message() -> Message: @@ -67,13 +66,24 @@ def build_message() -> Message: ) +def build_capturing_client() -> tuple[httpx2.AsyncClient, list[httpx2.Request]]: + """Builds an HTTP client that records requests and replies with a success.""" + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return build_success_response(request) + + client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) + return client, requests + + async def send_message( client: Client, - url: str, + requests: list[httpx2.Request], session_id: str | None = None, -) -> httpx.Request: - """Mocks the response and sends a message using the client.""" - respx.post(url).mock(side_effect=build_success_response) +) -> httpx2.Request: + """Sends a message using the client and returns the request it made.""" context = ClientCallContext( state={'sessionId': session_id} if session_id else {} ) @@ -83,7 +93,7 @@ async def send_message( context=context, ): pass - return respx.calls.last.request + return requests[-1] @pytest.fixture @@ -244,7 +254,6 @@ class AuthTestCase: 'test_case', [api_key_test_case, oauth2_test_case, oidc_test_case, bearer_test_case], ) -@respx.mock async def test_auth_interceptor_variants( test_case: AuthTestCase, store: InMemoryContextCredentialStore ) -> None: @@ -276,7 +285,8 @@ async def test_auth_interceptor_variants( }, ) - async with httpx.AsyncClient() as http_client: + http_client, requests = build_capturing_client() + async with http_client: config = ClientConfig( httpx_client=http_client, supported_protocol_bindings=[TransportProtocol.JSONRPC], @@ -284,9 +294,7 @@ async def test_auth_interceptor_variants( factory = ClientFactory(config) client = factory.create(agent_card, interceptors=[auth_interceptor]) - request = await send_message( - client, test_case.url, test_case.session_id - ) + request = await send_message(client, requests, test_case.session_id) assert request.headers[ test_case.expected_header_key ] == test_case.expected_header_value_func(test_case.credential) diff --git a/tests/client/test_card_resolver.py b/tests/client/test_card_resolver.py index a96402542..eacfa6dc8 100644 --- a/tests/client/test_card_resolver.py +++ b/tests/client/test_card_resolver.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock -import httpx +import httpx2 import pytest from a2a.client import A2ACardResolver, AgentCardResolutionError @@ -36,7 +36,7 @@ @pytest.fixture def mock_httpx_client(): """Fixture providing a mocked async httpx client.""" - return AsyncMock(spec=httpx.AsyncClient) + return AsyncMock(spec=httpx2.AsyncClient) @pytest.fixture @@ -57,7 +57,7 @@ def resolver(mock_httpx_client, base_url): @pytest.fixture def mock_response(): """Fixture providing a mock httpx Response.""" - response = Mock(spec=httpx.Response) + response = Mock(spec=httpx2.Response) response.raise_for_status = Mock() return response @@ -198,7 +198,7 @@ async def test_get_agent_card_with_http_kwargs( mock_response, valid_agent_card_data, ): - """Test that http_kwargs are passed to httpx.get.""" + """Test that http_kwargs are passed to httpx2.get.""" mock_response.json.return_value = valid_agent_card_data mock_httpx_client.get.return_value = mock_response http_kwargs = { @@ -249,9 +249,9 @@ async def test_get_agent_card_http_status_error( ): """Test A2AClientHTTPError raised on HTTP status error.""" status_code = 404 - mock_response = Mock(spec=httpx.Response) + mock_response = Mock(spec=httpx2.Response) mock_response.status_code = status_code - mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + mock_response.raise_for_status.side_effect = httpx2.HTTPStatusError( 'Not Found', request=Mock(), response=mock_response ) mock_httpx_client.get.return_value = mock_response @@ -281,7 +281,7 @@ async def test_get_agent_card_request_error( self, resolver, mock_httpx_client ): """Test A2AClientHTTPError raised on network request error.""" - mock_httpx_client.get.side_effect = httpx.RequestError( + mock_httpx_client.get.side_effect = httpx2.RequestError( 'Connection timeout', request=Mock() ) with pytest.raises(AgentCardResolutionError) as exc_info: @@ -329,10 +329,10 @@ async def test_get_agent_card_non_object_json( self, response_body: str ) -> None: """Non-object JSON responses use the documented resolution error.""" - transport = httpx.MockTransport( - lambda _request: httpx.Response(200, content=response_body) + transport = httpx2.MockTransport( + lambda _request: httpx2.Response(200, content=response_body) ) - async with httpx.AsyncClient(transport=transport) as client: + async with httpx2.AsyncClient(transport=transport) as client: resolver = A2ACardResolver(client, 'https://example.com') with pytest.raises(AgentCardResolutionError) as exc_info: await resolver.get_agent_card() @@ -370,10 +370,10 @@ async def test_get_agent_card_malformed_nested_fields( self, response_body: str, original_error: type[Exception] ) -> None: """Malformed legacy fields preserve the documented error chain.""" - transport = httpx.MockTransport( - lambda _request: httpx.Response(200, content=response_body) + transport = httpx2.MockTransport( + lambda _request: httpx2.Response(200, content=response_body) ) - async with httpx.AsyncClient(transport=transport) as client: + async with httpx2.AsyncClient(transport=transport) as client: resolver = A2ACardResolver(client, 'https://example.com') with pytest.raises(AgentCardResolutionError) as exc_info: await resolver.get_agent_card() @@ -445,9 +445,9 @@ async def test_get_agent_card_different_status_codes( self, resolver, mock_httpx_client, status_code ): """Test different HTTP status codes raise appropriate errors.""" - mock_response = Mock(spec=httpx.Response) + mock_response = Mock(spec=httpx2.Response) mock_response.status_code = status_code - mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + mock_response.raise_for_status.side_effect = httpx2.HTTPStatusError( f'Status {status_code}', request=Mock(), response=mock_response ) mock_httpx_client.get.return_value = mock_response @@ -473,7 +473,7 @@ async def test_get_agent_card_with_signature_verifier( """Test that the signature verifier is called if provided.""" mock_verifier = MagicMock() - mock_response = MagicMock(spec=httpx.Response) + mock_response = MagicMock(spec=httpx2.Response) mock_response.json.return_value = valid_agent_card_data mock_httpx_client.get.return_value = mock_response diff --git a/tests/client/test_client_factory.py b/tests/client/test_client_factory.py index 10380d6c9..0a900429d 100644 --- a/tests/client/test_client_factory.py +++ b/tests/client/test_client_factory.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch -import httpx +import httpx2 import pytest from a2a.client import ClientConfig, ClientFactory, create_client @@ -48,7 +48,7 @@ def base_agent_card() -> AgentCard: def test_client_factory_selects_preferred_transport(base_agent_card: AgentCard): """Verify that the factory selects the preferred transport by default.""" config = ClientConfig( - httpx_client=httpx.AsyncClient(), + httpx_client=httpx2.AsyncClient(), supported_protocol_bindings=[ TransportProtocol.JSONRPC, TransportProtocol.HTTP_JSON, @@ -73,7 +73,7 @@ def test_client_factory_selects_secondary_transport_url( ) # Client prefers REST, which is available as a secondary transport config = ClientConfig( - httpx_client=httpx.AsyncClient(), + httpx_client=httpx2.AsyncClient(), supported_protocol_bindings=[ TransportProtocol.HTTP_JSON, TransportProtocol.JSONRPC, @@ -105,7 +105,7 @@ def test_client_factory_server_preference(base_agent_card: AgentCard): ) # Client supports both, but server prefers REST config = ClientConfig( - httpx_client=httpx.AsyncClient(), + httpx_client=httpx2.AsyncClient(), supported_protocol_bindings=[ TransportProtocol.JSONRPC, TransportProtocol.HTTP_JSON, @@ -139,7 +139,7 @@ def test_client_factory_prefers_1_0_ignoring_patch_version( ] ) config = ClientConfig( - httpx_client=httpx.AsyncClient(), + httpx_client=httpx2.AsyncClient(), supported_protocol_bindings=[TransportProtocol.JSONRPC], ) client = ClientFactory(config).create(base_agent_card) @@ -151,7 +151,7 @@ def test_client_factory_prefers_1_0_ignoring_patch_version( def test_client_factory_no_compatible_transport(base_agent_card: AgentCard): """Verify that the factory raises an error if no compatible transport is found.""" config = ClientConfig( - httpx_client=httpx.AsyncClient(), + httpx_client=httpx2.AsyncClient(), supported_protocol_bindings=['UNKNOWN_PROTOCOL'], ) factory = ClientFactory(config) @@ -200,7 +200,7 @@ async def test_client_factory_create_from_url_uses_factory_httpx_client( ) agent_url = 'http://example.com' - mock_httpx_client = httpx.AsyncClient() + mock_httpx_client = httpx2.AsyncClient() config = ClientConfig(httpx_client=mock_httpx_client) factory = ClientFactory(config) @@ -227,7 +227,7 @@ async def test_client_factory_create_from_url_passes_resolver_args( relative_path = '/extendedAgentCard' http_kwargs = {'headers': {'X-Test': 'true'}} - config = ClientConfig(httpx_client=httpx.AsyncClient()) + config = ClientConfig(httpx_client=httpx2.AsyncClient()) factory = ClientFactory(config) await factory.create_from_url( @@ -360,7 +360,7 @@ def test_client_factory_applies_tenant_decorator(base_agent_card: AgentCard): """Verify that the factory applies TenantTransportDecorator when tenant is present.""" base_agent_card.supported_interfaces[0].tenant = 'my-tenant' config = ClientConfig( - httpx_client=httpx.AsyncClient(), + httpx_client=httpx2.AsyncClient(), supported_protocol_bindings=[TransportProtocol.JSONRPC], ) factory = ClientFactory(config) @@ -402,7 +402,7 @@ async def test_create_client_with_url_and_config(base_agent_card: AgentCard): return_value=base_agent_card ) - mock_httpx_client = httpx.AsyncClient() + mock_httpx_client = httpx2.AsyncClient() config = ClientConfig(httpx_client=mock_httpx_client) await create_client('http://example.com', client_config=config) @@ -433,7 +433,7 @@ async def test_client_close_does_not_close_shared_httpx_client( base_agent_card: AgentCard, ): """Verify closing a client does not close a shared httpx_client from config.""" - shared_httpx_client = httpx.AsyncClient() + shared_httpx_client = httpx2.AsyncClient() try: factory = ClientFactory(ClientConfig(httpx_client=shared_httpx_client)) client = factory.create(base_agent_card) diff --git a/tests/client/transports/test_http_helpers.py b/tests/client/transports/test_http_helpers.py index 8ef5d2d89..5aba7ae2b 100644 --- a/tests/client/transports/test_http_helpers.py +++ b/tests/client/transports/test_http_helpers.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 import pytest from a2a.client.errors import A2AClientError @@ -28,7 +28,7 @@ async def mock_aiter_lines(): yield 'data: \n' yield '\n' - response = httpx.Response(200) + response = httpx2.Response(200) response.aiter_lines = mock_aiter_lines # type: ignore events = [e async for e in parse_sse_stream(response)] @@ -40,9 +40,9 @@ async def mock_aiter_lines(): @pytest.mark.asyncio async def test_send_http_stream_request_non_sse(mocker): - client = httpx.AsyncClient() - request = httpx.Request('GET', 'http://test') - response = httpx.Response( + client = httpx2.AsyncClient() + request = httpx2.Request('GET', 'http://test') + response = httpx2.Response( 200, headers={'Content-Type': 'application/json'}, content=b'plain error response', diff --git a/tests/client/transports/test_jsonrpc_client.py b/tests/client/transports/test_jsonrpc_client.py index 43a347799..3d5f2c3ff 100644 --- a/tests/client/transports/test_jsonrpc_client.py +++ b/tests/client/transports/test_jsonrpc_client.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 -import httpx +import httpx2 import pytest from a2a.client.errors import A2AClientError @@ -44,11 +44,11 @@ async def async_iterable_from_list( @pytest.fixture def mock_httpx_client(): - """Creates a mock httpx.AsyncClient.""" - client = AsyncMock(spec=httpx.AsyncClient) + """Creates a mock httpx2.AsyncClient.""" + client = AsyncMock(spec=httpx2.AsyncClient) client.is_closed = False - client.headers = httpx.Headers() - client.timeout = httpx.Timeout(30.0) + client.headers = httpx2.Headers() + client.timeout = httpx2.Timeout(30.0) return client @@ -185,7 +185,7 @@ async def test_send_message_jsonrpc_error( @pytest.mark.asyncio async def test_send_message_timeout(self, transport, mock_httpx_client): """Test handling of request timeout.""" - mock_httpx_client.send.side_effect = httpx.ReadTimeout('Timeout') + mock_httpx_client.send.side_effect = httpx2.ReadTimeout('Timeout') request = create_send_message_request() @@ -197,7 +197,7 @@ async def test_send_message_http_error(self, transport, mock_httpx_client): """Test handling of HTTP errors.""" mock_response = MagicMock() mock_response.status_code = 500 - mock_httpx_client.send.side_effect = httpx.HTTPStatusError( + mock_httpx_client.send.side_effect = httpx2.HTTPStatusError( 'Server Error', request=MagicMock(), response=mock_response ) @@ -245,7 +245,7 @@ async def test_send_message_with_timeout_context( mock_httpx_client.build_request.assert_called_once() _, kwargs = mock_httpx_client.build_request.call_args assert 'timeout' in kwargs - assert kwargs['timeout'] == httpx.Timeout(15.0) + assert kwargs['timeout'] == httpx2.Timeout(15.0) class TestGetTask: @@ -448,7 +448,7 @@ async def test_send_message_streaming_sse_error( transport: JsonRpcTransport, ): request = create_send_message_request() - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.raise_for_status = MagicMock() mock_response.headers = {'content-type': 'text/event-stream'} mock_response.aiter_lines.return_value = async_iterable_from_list( @@ -472,10 +472,10 @@ async def test_send_message_streaming_request_error( transport: JsonRpcTransport, ): request = create_send_message_request() - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.raise_for_status = MagicMock() mock_response.headers = {'content-type': 'text/event-stream'} - mock_response.aiter_lines.side_effect = httpx.RequestError( + mock_response.aiter_lines.side_effect = httpx2.RequestError( 'Simulated request error', request=MagicMock() ) mock_aconnect_sse.return_value.__aenter__.return_value = mock_response @@ -492,10 +492,10 @@ async def test_send_message_streaming_timeout( transport: JsonRpcTransport, ): request = create_send_message_request() - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.raise_for_status = MagicMock() mock_response.headers = {'content-type': 'text/event-stream'} - mock_response.aiter_lines.side_effect = httpx.TimeoutException( + mock_response.aiter_lines.side_effect = httpx2.TimeoutException( 'Timeout' ) mock_aconnect_sse.return_value.__aenter__.return_value = mock_response @@ -573,12 +573,12 @@ async def test_send_message_streaming_server_error_propagates( ) request = create_send_message_request(text='Error stream') - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 403 mock_response.headers = {'content-type': 'text/event-stream'} - mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + mock_response.raise_for_status.side_effect = httpx2.HTTPStatusError( 'Forbidden', - request=httpx.Request('POST', 'http://test.url'), + request=httpx2.Request('POST', 'http://test.url'), response=mock_response, ) mock_response.aiter_lines.return_value = async_iterable_from_list([]) @@ -810,7 +810,7 @@ async def test_send_message_streaming_ignores_unrecognized_fields( 'someNewField': 'x', }, } - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.raise_for_status = MagicMock() mock_response.headers = {'content-type': 'text/event-stream'} mock_response.aiter_lines.return_value = async_iterable_from_list( diff --git a/tests/client/transports/test_rest_client.py b/tests/client/transports/test_rest_client.py index 3c0a7da29..c5d36b65a 100644 --- a/tests/client/transports/test_rest_client.py +++ b/tests/client/transports/test_rest_client.py @@ -3,7 +3,7 @@ from collections.abc import AsyncGenerator from unittest.mock import AsyncMock, MagicMock, patch -import httpx +import httpx2 import pytest from a2a.client.client import ClientCallContext @@ -35,7 +35,7 @@ @pytest.fixture def mock_httpx_client() -> AsyncMock: - client = AsyncMock(spec=httpx.AsyncClient) + client = AsyncMock(spec=httpx2.AsyncClient) client.is_closed = False return client @@ -87,10 +87,10 @@ async def test_send_message_streaming_timeout( params = SendMessageRequest( message=new_text_message(text='Hello stream') ) - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.headers = {'content-type': 'text/event-stream'} mock_response.raise_for_status.return_value = None - mock_response.aiter_lines.side_effect = httpx.TimeoutException( + mock_response.aiter_lines.side_effect = httpx2.TimeoutException( 'Read timed out' ) mock_aconnect_sse.return_value.__aenter__.return_value = mock_response @@ -120,11 +120,11 @@ async def test_rest_mapped_errors( params = SendMessageRequest(message=new_text_message(text='Hello')) mock_build_request = MagicMock( - return_value=AsyncMock(spec=httpx.Request) + return_value=AsyncMock(spec=httpx2.Request) ) mock_httpx_client.build_request = mock_build_request - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 500 reason = A2A_ERROR_MAPPING[error_cls][2] @@ -145,9 +145,9 @@ async def test_rest_mapped_errors( } } - error = httpx.HTTPStatusError( + error = httpx2.HTTPStatusError( 'Server Error', - request=httpx.Request('POST', 'http://test.url'), + request=httpx2.Request('POST', 'http://test.url'), response=mock_response, ) @@ -180,12 +180,12 @@ def test_rest_non_status_error_bodies( agent_card=mock_agent_card, url='http://agent.example.com/api', ) - mock_response = MagicMock(spec=httpx.Response) + mock_response = MagicMock(spec=httpx2.Response) mock_response.status_code = status_code mock_response.json.return_value = body - error = httpx.HTTPStatusError( + error = httpx2.HTTPStatusError( 'Error', - request=httpx.Request('GET', 'http://test.url'), + request=httpx2.Request('GET', 'http://test.url'), response=mock_response, ) @@ -207,11 +207,11 @@ async def test_send_message_with_timeout_context( context = ClientCallContext(timeout=10.0) mock_build_request = MagicMock( - return_value=AsyncMock(spec=httpx.Request) + return_value=AsyncMock(spec=httpx2.Request) ) mock_httpx_client.build_request = mock_build_request - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 200 mock_httpx_client.send.return_value = mock_response @@ -220,7 +220,7 @@ async def test_send_message_with_timeout_context( mock_build_request.assert_called_once() _, kwargs = mock_build_request.call_args assert 'timeout' in kwargs - assert kwargs['timeout'] == httpx.Timeout(10.0) + assert kwargs['timeout'] == httpx2.Timeout(10.0) @pytest.mark.asyncio async def test_url_serialization( @@ -245,10 +245,10 @@ async def test_url_serialization( # Use real build_request to get actual URL serialization mock_httpx_client.build_request.side_effect = ( - httpx.AsyncClient().build_request + httpx2.AsyncClient().build_request ) mock_httpx_client.send.return_value = AsyncMock( - spec=httpx.Response, status_code=200, json=lambda: {'tasks': []} + spec=httpx2.Response, status_code=200, json=lambda: {'tasks': []} ) await client.list_tasks(request=request) @@ -279,12 +279,12 @@ async def test_send_message_with_default_extensions( # Mock the build_request method to capture its inputs mock_build_request = MagicMock( - return_value=AsyncMock(spec=httpx.Request) + return_value=AsyncMock(spec=httpx2.Request) ) mock_httpx_client.build_request = mock_build_request # Mock the send method - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 200 mock_httpx_client.send.return_value = mock_response @@ -324,7 +324,7 @@ async def test_send_message_streaming_with_new_extensions( message=new_text_message(text='Hello stream') ) - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.headers = {'content-type': 'text/event-stream'} mock_response.raise_for_status.return_value = None mock_response.aiter_lines.return_value = async_iterable_from_list([]) @@ -369,12 +369,12 @@ async def test_send_message_streaming_server_error_propagates( message=new_text_message(text='Error stream') ) - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 403 mock_response.headers = {'content-type': 'text/event-stream'} - mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + mock_response.raise_for_status.side_effect = httpx2.HTTPStatusError( 'Forbidden', - request=httpx.Request('POST', 'http://test.url'), + request=httpx2.Request('POST', 'http://test.url'), response=mock_response, ) mock_response.aiter_lines.return_value = async_iterable_from_list([]) @@ -413,7 +413,7 @@ async def test_get_card_with_extended_card_support_with_extensions( url='http://agent.example.com/api', ) - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 200 mock_response.json.return_value = json_format.MessageToDict( agent_card @@ -460,7 +460,7 @@ async def test_list_task_push_notification_configs_success( url='http://agent.example.com/api', ) task_id = 'task-1' - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 200 mock_response.json.return_value = { 'configs': [ @@ -475,7 +475,7 @@ async def test_list_task_push_notification_configs_success( # Mock the build_request method to capture its inputs mock_build_request = MagicMock( - return_value=AsyncMock(spec=httpx.Request) + return_value=AsyncMock(spec=httpx2.Request) ) mock_httpx_client.build_request = mock_build_request @@ -503,14 +503,14 @@ async def test_delete_task_push_notification_config_success( url='http://agent.example.com/api', ) task_id = 'task-1' - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 200 mock_response.json.return_value = {} mock_httpx_client.send.return_value = mock_response # Mock the build_request method to capture its inputs mock_build_request = MagicMock( - return_value=AsyncMock(spec=httpx.Request) + return_value=AsyncMock(spec=httpx2.Request) ) mock_httpx_client.build_request = mock_build_request @@ -608,10 +608,10 @@ async def test_rest_methods_prepend_tenant( # 2. Setup mocks mock_httpx_client.build_request.return_value = MagicMock( - spec=httpx.Request + spec=httpx2.Request ) mock_httpx_client.send.return_value = AsyncMock( - spec=httpx.Response, + spec=httpx2.Response, status_code=200, json=MagicMock(return_value={}), ) @@ -640,10 +640,10 @@ async def test_rest_get_extended_agent_card_prepend_tenant( # 1. Setup mocks mock_httpx_client.build_request.return_value = MagicMock( - spec=httpx.Request + spec=httpx2.Request ) mock_httpx_client.send.return_value = AsyncMock( - spec=httpx.Response, + spec=httpx2.Response, status_code=200, json=MagicMock(return_value={}), ) @@ -674,10 +674,10 @@ async def test_rest_get_task_prepend_empty_tenant( # 1. Setup mocks mock_httpx_client.build_request.return_value = MagicMock( - spec=httpx.Request + spec=httpx2.Request ) mock_httpx_client.send.return_value = AsyncMock( - spec=httpx.Response, + spec=httpx2.Response, status_code=200, json=MagicMock(return_value={}), ) @@ -728,7 +728,7 @@ async def test_rest_streaming_methods_prepend_tenant( # noqa: PLR0913 method = getattr(client, method_name) # 2. Setup mocks - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.headers = {'content-type': 'text/event-stream'} mock_response.raise_for_status.return_value = None mock_response.aiter_lines.return_value = async_iterable_from_list([]) @@ -766,9 +766,9 @@ async def test_get_task_ignores_unrecognized_fields( url='http://agent.example.com/api', ) mock_httpx_client.build_request = MagicMock( - return_value=AsyncMock(spec=httpx.Request) + return_value=AsyncMock(spec=httpx2.Request) ) - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.raise_for_status = MagicMock() mock_response.json = MagicMock( return_value={ @@ -811,7 +811,7 @@ async def test_send_message_streaming_ignores_unrecognized_fields( }, 'someNewField': 'x', } - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.raise_for_status = MagicMock() mock_response.headers = {'content-type': 'text/event-stream'} mock_response.aiter_lines.return_value = async_iterable_from_list( diff --git a/tests/compat/v0_3/test_jsonrpc_transport.py b/tests/compat/v0_3/test_jsonrpc_transport.py index 13d91162a..425f4f7ac 100644 --- a/tests/compat/v0_3/test_jsonrpc_transport.py +++ b/tests/compat/v0_3/test_jsonrpc_transport.py @@ -1,6 +1,6 @@ from unittest.mock import AsyncMock, MagicMock, patch -import httpx +import httpx2 import pytest from a2a.client.errors import A2AClientError @@ -31,7 +31,7 @@ @pytest.fixture def mock_httpx_client(): - client = AsyncMock(spec=httpx.AsyncClient) + client = AsyncMock(spec=httpx2.AsyncClient) client.is_closed = False return client @@ -435,13 +435,13 @@ async def mock_send_stream_request(*args, **kwargs): def test_compat_jsonrpc_transport_handle_http_error(transport): - mock_response = MagicMock(spec=httpx.Response) + mock_response = MagicMock(spec=httpx2.Response) mock_response.status_code = 400 - mock_request = MagicMock(spec=httpx.Request) + mock_request = MagicMock(spec=httpx2.Request) mock_request.url = 'http://example.com' - error = httpx.HTTPStatusError( + error = httpx2.HTTPStatusError( 'Error', request=mock_request, response=mock_response ) @@ -526,7 +526,7 @@ async def test_compat_jsonrpc_transport_send_request( mock_send_http_request, transport ): mock_send_http_request.return_value = {'result': {'ok': True}} - mock_request = httpx.Request('POST', 'http://example.com') + mock_request = httpx2.Request('POST', 'http://example.com') transport.httpx_client.build_request.return_value = mock_request res = await transport._send_request({'some': 'data'}) @@ -553,7 +553,7 @@ async def test_compat_jsonrpc_transport_mirrors_extension_header( from a2a.client.client import ClientCallContext mock_send_http_request.return_value = {'result': {'ok': True}} - transport.httpx_client.build_request.return_value = httpx.Request( + transport.httpx_client.build_request.return_value = httpx2.Request( 'POST', 'http://example.com' ) diff --git a/tests/compat/v0_3/test_rest_routes_compat.py b/tests/compat/v0_3/test_rest_routes_compat.py index 3886e944c..4fcd7bec9 100644 --- a/tests/compat/v0_3/test_rest_routes_compat.py +++ b/tests/compat/v0_3/test_rest_routes_compat.py @@ -21,7 +21,7 @@ ) from fastapi import FastAPI from google.protobuf import json_format -from httpx import ASGITransport, AsyncClient +from httpx2 import ASGITransport, AsyncClient from starlette.applications import Starlette from starlette.datastructures import Headers from starlette.requests import Request diff --git a/tests/compat/v0_3/test_rest_transport.py b/tests/compat/v0_3/test_rest_transport.py index e627b444f..e0ff541b1 100644 --- a/tests/compat/v0_3/test_rest_transport.py +++ b/tests/compat/v0_3/test_rest_transport.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch -import httpx +import httpx2 import pytest from a2a.client.errors import A2AClientError @@ -32,7 +32,7 @@ @pytest.fixture def mock_httpx_client(): - client = AsyncMock(spec=httpx.AsyncClient) + client = AsyncMock(spec=httpx2.AsyncClient) client.is_closed = False return client @@ -55,7 +55,7 @@ def transport(mock_httpx_client, agent_card): async def test_compat_rest_transport_send_message_response_msg_parsing( transport, ): - mock_response = MagicMock(spec=httpx.Response) + mock_response = MagicMock(spec=httpx2.Response) mock_response.json.return_value = { 'msg': {'messageId': 'msg-123', 'role': 'agent'} } @@ -79,7 +79,7 @@ async def mock_send_request(*args, **kwargs): @pytest.mark.asyncio async def test_compat_rest_transport_send_message_task(transport): - mock_response = MagicMock(spec=httpx.Response) + mock_response = MagicMock(spec=httpx2.Response) mock_response.json.return_value = {'task': {'id': 'task-123'}} async def mock_send_request(*args, **kwargs): @@ -233,31 +233,31 @@ async def mock_send_stream_request(*args, **kwargs): def create_405_error(): - mock_response = MagicMock(spec=httpx.Response) + mock_response = MagicMock(spec=httpx2.Response) mock_response.status_code = 405 mock_response.json.return_value = { 'type': 'MethodNotAllowed', 'message': 'Method Not Allowed', } - mock_request = MagicMock(spec=httpx.Request) + mock_request = MagicMock(spec=httpx2.Request) mock_request.url = 'http://example.com/v1/tasks/task-123:subscribe' - status_error = httpx.HTTPStatusError( + status_error = httpx2.HTTPStatusError( '405 Method Not Allowed', request=mock_request, response=mock_response ) raise A2AClientError('HTTP Error 405') from status_error def create_500_error(): - mock_response = MagicMock(spec=httpx.Response) + mock_response = MagicMock(spec=httpx2.Response) mock_response.status_code = 500 mock_response.json.return_value = { 'type': 'InternalError', 'message': 'Internal Error', } - mock_request = MagicMock(spec=httpx.Request) + mock_request = MagicMock(spec=httpx2.Request) - status_error = httpx.HTTPStatusError( + status_error = httpx2.HTTPStatusError( '500 Internal Error', request=mock_request, response=mock_response ) raise A2AClientError('HTTP Error 500') from status_error @@ -448,17 +448,17 @@ async def mock_stream(method, path, context=None, json=None): def test_compat_rest_transport_handle_http_error(transport): - mock_response = MagicMock(spec=httpx.Response) + mock_response = MagicMock(spec=httpx2.Response) mock_response.status_code = 400 mock_response.json.return_value = { 'type': 'InvalidParamsError', 'message': 'Invalid parameters', } - mock_request = MagicMock(spec=httpx.Request) + mock_request = MagicMock(spec=httpx2.Request) mock_request.url = 'http://example.com' - error = httpx.HTTPStatusError( + error = httpx2.HTTPStatusError( 'Error', request=mock_request, response=mock_response ) @@ -469,14 +469,14 @@ def test_compat_rest_transport_handle_http_error(transport): def test_compat_rest_transport_handle_http_error_not_found(transport): - mock_response = MagicMock(spec=httpx.Response) + mock_response = MagicMock(spec=httpx2.Response) mock_response.status_code = 404 mock_response.json.side_effect = json.JSONDecodeError('msg', 'doc', 0) - mock_request = MagicMock(spec=httpx.Request) + mock_request = MagicMock(spec=httpx2.Request) mock_request.url = 'http://example.com' - error = httpx.HTTPStatusError( + error = httpx2.HTTPStatusError( 'Error', request=mock_request, response=mock_response ) @@ -485,14 +485,14 @@ def test_compat_rest_transport_handle_http_error_not_found(transport): def test_compat_rest_transport_handle_http_error_generic(transport): - mock_response = MagicMock(spec=httpx.Response) + mock_response = MagicMock(spec=httpx2.Response) mock_response.status_code = 500 mock_response.json.side_effect = json.JSONDecodeError('msg', 'doc', 0) - mock_request = MagicMock(spec=httpx.Request) + mock_request = MagicMock(spec=httpx2.Request) mock_request.url = 'http://example.com' - error = httpx.HTTPStatusError( + error = httpx2.HTTPStatusError( 'Error', request=mock_request, response=mock_response ) @@ -570,17 +570,17 @@ async def mock_execute_request(method, path, context=None, params=None): def test_compat_rest_transport_handle_http_error_invalid_error_type(transport): - mock_response = MagicMock(spec=httpx.Response) + mock_response = MagicMock(spec=httpx2.Response) mock_response.status_code = 500 mock_response.json.return_value = { 'type': 123, 'message': 'Invalid parameters', } - mock_request = MagicMock(spec=httpx.Request) + mock_request = MagicMock(spec=httpx2.Request) mock_request.url = 'http://example.com' - error = httpx.HTTPStatusError( + error = httpx2.HTTPStatusError( 'Error', request=mock_request, response=mock_response ) @@ -599,14 +599,14 @@ def test_compat_rest_transport_handle_http_error_invalid_error_type(transport): def test_compat_rest_transport_handle_http_error_non_object_body( transport, status_code, body, expected_error ): - mock_response = MagicMock(spec=httpx.Response) + mock_response = MagicMock(spec=httpx2.Response) mock_response.status_code = status_code mock_response.json.return_value = body - mock_request = MagicMock(spec=httpx.Request) + mock_request = MagicMock(spec=httpx2.Request) mock_request.url = 'http://example.com' - error = httpx.HTTPStatusError( + error = httpx2.HTTPStatusError( 'Error', request=mock_request, response=mock_response ) @@ -615,17 +615,17 @@ def test_compat_rest_transport_handle_http_error_non_object_body( def test_compat_rest_transport_handle_http_error_unknown_error_type(transport): - mock_response = MagicMock(spec=httpx.Response) + mock_response = MagicMock(spec=httpx2.Response) mock_response.status_code = 500 mock_response.json.return_value = { 'type': 'SomeUnknownErrorClass', 'message': 'Unknown', } - mock_request = MagicMock(spec=httpx.Request) + mock_request = MagicMock(spec=httpx2.Request) mock_request.url = 'http://example.com' - error = httpx.HTTPStatusError( + error = httpx2.HTTPStatusError( 'Error', request=mock_request, response=mock_response ) @@ -667,7 +667,7 @@ async def test_compat_rest_transport_execute_request( mock_send_http_request, transport ): mock_send_http_request.return_value = {'ok': True} - mock_request = httpx.Request('POST', 'http://example.com') + mock_request = httpx2.Request('POST', 'http://example.com') transport.httpx_client.build_request.return_value = mock_request res = await transport._execute_request( diff --git a/tests/integration/cross_version/client_server/client_0_3.py b/tests/integration/cross_version/client_server/client_0_3.py index 70a3263bd..ed67e2789 100644 --- a/tests/integration/cross_version/client_server/client_0_3.py +++ b/tests/integration/cross_version/client_server/client_0_3.py @@ -6,7 +6,7 @@ from uuid import uuid4 import grpc -import httpx +import httpx2 from a2a.client import ClientConfig, ClientFactory from a2a.types import ( @@ -75,7 +75,7 @@ async def test_send_message_stream(client): async def test_send_message_sync(url, protocol_enum): print('Testing send_message (synchronous)...') config = ClientConfig() - config.httpx_client = httpx.AsyncClient(timeout=30.0) + config.httpx_client = httpx2.AsyncClient(timeout=30.0) config.grpc_channel_factory = grpc.aio.insecure_channel config.supported_transports = [protocol_enum] config.streaming = False @@ -238,7 +238,7 @@ async def run_client(url: str, protocol: str): protocol_enum = protocol_enum_map[protocol] config = ClientConfig() - config.httpx_client = httpx.AsyncClient(timeout=30.0) + config.httpx_client = httpx2.AsyncClient(timeout=30.0) config.grpc_channel_factory = grpc.aio.insecure_channel config.supported_transports = [protocol_enum] config.streaming = True diff --git a/tests/integration/cross_version/client_server/client_1_0.py b/tests/integration/cross_version/client_server/client_1_0.py index 6e463854b..f41447926 100644 --- a/tests/integration/cross_version/client_server/client_1_0.py +++ b/tests/integration/cross_version/client_server/client_1_0.py @@ -5,7 +5,7 @@ from uuid import uuid4 import grpc -import httpx +import httpx2 from a2a.client import ClientConfig, create_client from a2a.client.errors import A2AClientError @@ -77,7 +77,7 @@ async def test_send_message_stream(client): async def test_send_message_sync(url, protocol_enum): print('Testing send_message (synchronous)...') config = ClientConfig() - config.httpx_client = httpx.AsyncClient(timeout=30.0) + config.httpx_client = httpx2.AsyncClient(timeout=30.0) config.grpc_channel_factory = grpc.aio.insecure_channel config.supported_protocol_bindings = [protocol_enum] config.streaming = False @@ -292,7 +292,7 @@ async def run_client(url: str, protocol: str): protocol_enum = protocol_enum_map[protocol] config = ClientConfig() - config.httpx_client = httpx.AsyncClient(timeout=30.0) + config.httpx_client = httpx2.AsyncClient(timeout=30.0) config.grpc_channel_factory = grpc.aio.insecure_channel config.supported_protocol_bindings = [protocol_enum] config.streaming = True diff --git a/tests/integration/push_notifications/agent_app.py b/tests/integration/push_notifications/agent_app.py index 127ee9d1e..a4fb3176c 100644 --- a/tests/integration/push_notifications/agent_app.py +++ b/tests/integration/push_notifications/agent_app.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 from a2a.auth.user import UnauthenticatedUser, User from a2a.helpers.proto_helpers import ( @@ -138,7 +138,7 @@ async def cancel( def create_agent_app( - url: str, notification_client: httpx.AsyncClient + url: str, notification_client: httpx2.AsyncClient ) -> Starlette: """Creates a new HTTP+REST Starlette application for the test agent.""" push_config_store = InMemoryPushNotificationConfigStore() @@ -191,7 +191,7 @@ def build_user(self, request: Request) -> User: def create_multi_user_agent_app( - url: str, notification_client: httpx.AsyncClient + url: str, notification_client: httpx2.AsyncClient ) -> Starlette: """Creates a multi-user variant of the test agent app. diff --git a/tests/integration/push_notifications/test_default_push_notification_support.py b/tests/integration/push_notifications/test_default_push_notification_support.py index eb7928ba9..a09d52d51 100644 --- a/tests/integration/push_notifications/test_default_push_notification_support.py +++ b/tests/integration/push_notifications/test_default_push_notification_support.py @@ -2,7 +2,7 @@ import time import uuid -import httpx +import httpx2 import pytest import pytest_asyncio @@ -61,12 +61,12 @@ def notifications_server(): @pytest_asyncio.fixture(scope='module') async def notifications_client(): """An async client fixture for calling the notifications server.""" - async with httpx.AsyncClient() as client: + async with httpx2.AsyncClient() as client: yield client @pytest.fixture(scope='module') -def agent_server(notifications_client: httpx.AsyncClient): +def agent_server(notifications_client: httpx2.AsyncClient): """Starts a test agent server and yields its URL.""" host = '127.0.0.1' port = find_free_port() @@ -91,7 +91,7 @@ def agent_server(notifications_client: httpx.AsyncClient): @pytest.fixture(scope='module') -def multi_user_agent_server(notifications_client: httpx.AsyncClient): +def multi_user_agent_server(notifications_client: httpx2.AsyncClient): """Starts the multi-user variant of the test agent server. This variant reads identity from an x-test-user request header @@ -127,7 +127,7 @@ def multi_user_agent_server(notifications_client: httpx.AsyncClient): @pytest_asyncio.fixture(scope='function') async def http_client(): """An async client fixture for test functions.""" - async with httpx.AsyncClient() as client: + async with httpx2.AsyncClient() as client: yield client @@ -135,7 +135,7 @@ async def http_client(): async def test_notification_triggering_with_in_message_config_e2e( notifications_server: str, agent_server: str, - http_client: httpx.AsyncClient, + http_client: httpx2.AsyncClient, ): """ Tests push notification triggering for in-message push notification config. @@ -195,7 +195,9 @@ async def test_notification_triggering_with_in_message_config_e2e( @pytest.mark.asyncio async def test_notification_triggering_after_config_change_e2e( - notifications_server: str, agent_server: str, http_client: httpx.AsyncClient + notifications_server: str, + agent_server: str, + http_client: httpx2.AsyncClient, ): """ Tests notification triggering after setting the push notification config in a separate call. @@ -278,7 +280,7 @@ async def test_notification_triggering_after_config_change_e2e( async def test_multi_registrar_fan_out_e2e( notifications_server: str, agent_server: str, - http_client: httpx.AsyncClient, + http_client: httpx2.AsyncClient, ): """Two pushNotificationConfigs registered for the same task both fire end-to-end. @@ -387,10 +389,10 @@ def _make_user_a2a_client(agent_server: str, user_name: str): """Builds an A2A client that identifies as user_name on every request. Identity is conveyed via a default header on the underlying - httpx.AsyncClient; the multi-user agent app's context builder + httpx2.AsyncClient; the multi-user agent app's context builder reads that header to populate ServerCallContext.user. """ - httpx_client = httpx.AsyncClient(headers={_TEST_USER_HEADER: user_name}) + httpx_client = httpx2.AsyncClient(headers={_TEST_USER_HEADER: user_name}) return ClientFactory( ClientConfig( httpx_client=httpx_client, @@ -405,7 +407,7 @@ def _make_user_a2a_client(agent_server: str, user_name: str): async def test_alice_and_bob_both_receive_notifications_on_shared_task_e2e( notifications_server: str, multi_user_agent_server: str, - http_client: httpx.AsyncClient, + http_client: httpx2.AsyncClient, ): """Alice registers a webhook; Bob registers a webhook; both fire end-to-end. @@ -541,7 +543,7 @@ async def test_alice_and_bob_both_receive_notifications_on_shared_task_e2e( async def wait_for_n_notifications( - http_client: httpx.AsyncClient, + http_client: httpx2.AsyncClient, url: str, n: int, timeout: int = 3, diff --git a/tests/integration/push_notifications/utils.py b/tests/integration/push_notifications/utils.py index a7317f1b2..6f5046ce7 100644 --- a/tests/integration/push_notifications/utils.py +++ b/tests/integration/push_notifications/utils.py @@ -4,7 +4,7 @@ import sys import time -import httpx +import httpx2 import uvicorn @@ -26,8 +26,8 @@ def wait_for_server_ready( """Polls the provided URL endpoint until the server is up.""" start_time = time.time() while True: - with contextlib.suppress(httpx.ConnectError): - with httpx.Client(headers=headers) as client: + with contextlib.suppress(httpx2.ConnectError): + with httpx2.Client(headers=headers) as client: response = client.get(url) if response.status_code == 200: return diff --git a/tests/integration/test_agent_card.py b/tests/integration/test_agent_card.py index 9cc6b142a..a1fd00e96 100644 --- a/tests/integration/test_agent_card.py +++ b/tests/integration/test_agent_card.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 import pytest from a2a.server.agent_execution import AgentExecutor, RequestContext @@ -115,8 +115,8 @@ async def test_agent_card_integration(header_val: str | None) -> None: headers[VERSION_HEADER] = header_val # 3. Use direct http client (ASGITransport) to fetch and assert - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url='http://testserver' + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url='http://testserver' ) as client: # Fetch from JSONRPC endpoint resp_jsonrpc = await client.get( diff --git a/tests/integration/test_client_server_integration.py b/tests/integration/test_client_server_integration.py index ea093aa7e..232e2eed7 100644 --- a/tests/integration/test_client_server_integration.py +++ b/tests/integration/test_client_server_integration.py @@ -5,7 +5,7 @@ from unittest.mock import ANY, AsyncMock, patch import grpc -import httpx +import httpx2 import pytest import pytest_asyncio @@ -245,7 +245,7 @@ def jsonrpc_setup(http_base_setup) -> TransportSetup: request_handler=mock_request_handler, rpc_url='/' ) app = Starlette(routes=[*agent_card_routes, *jsonrpc_routes]) - httpx_client = httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) + httpx_client = httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app)) factory = ClientFactory( config=ClientConfig( httpx_client=httpx_client, @@ -265,7 +265,7 @@ def rest_setup(http_base_setup) -> TransportSetup: agent_card=agent_card, card_url='/' ) app = Starlette(routes=[*rest_routes, *agent_card_routes]) - httpx_client = httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) + httpx_client = httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app)) factory = ClientFactory( config=ClientConfig( httpx_client=httpx_client, @@ -718,8 +718,8 @@ async def async_signer(card: AgentCard) -> AgentCard: request_handler=mock_request_handler, rpc_url='/' ) app = Starlette(routes=[*agent_card_routes, *jsonrpc_routes]) - httpx_client = httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), + httpx_client = httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), headers={VERSION_HEADER: PROTOCOL_VERSION_CURRENT}, ) @@ -795,8 +795,8 @@ async def get_extended_agent_card_mock_2(*args, **kwargs) -> AgentCard: request_handler=mock_request_handler, rpc_url='/' ) app = Starlette(routes=[*agent_card_routes, *jsonrpc_routes]) - httpx_client = httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), + httpx_client = httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), headers={VERSION_HEADER: PROTOCOL_VERSION_CURRENT}, ) @@ -876,8 +876,8 @@ async def async_signer(card: AgentCard) -> AgentCard: request_handler=mock_request_handler, rpc_url='/' ) app = Starlette(routes=[*agent_card_routes, *jsonrpc_routes]) - httpx_client = httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), + httpx_client = httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), headers={VERSION_HEADER: PROTOCOL_VERSION_CURRENT}, ) diff --git a/tests/integration/test_copying_observability.py b/tests/integration/test_copying_observability.py index c0d195c08..bdaff9baf 100644 --- a/tests/integration/test_copying_observability.py +++ b/tests/integration/test_copying_observability.py @@ -1,6 +1,6 @@ from typing import NamedTuple -import httpx +import httpx2 import pytest from a2a.client.client import Client, ClientConfig @@ -113,8 +113,8 @@ def setup_client(agent_card: AgentCard, use_copying: bool) -> ClientSetup: rpc_url='/', ) app = Starlette(routes=[*agent_card_routes, *jsonrpc_routes]) - httpx_client = httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url='http://testserver' + httpx_client = httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url='http://testserver' ) factory = ClientFactory( config=ClientConfig( diff --git a/tests/integration/test_end_to_end.py b/tests/integration/test_end_to_end.py index eeaf46416..4f7fccbe6 100644 --- a/tests/integration/test_end_to_end.py +++ b/tests/integration/test_end_to_end.py @@ -2,7 +2,7 @@ from typing import NamedTuple import grpc -import httpx +import httpx2 import pytest import pytest_asyncio @@ -224,8 +224,8 @@ def rest_setup(agent_card, base_e2e_setup) -> ClientSetup: agent_card=agent_card, card_url='/' ) app = Starlette(routes=[*rest_routes, *agent_card_routes]) - httpx_client = httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url='http://testserver' + httpx_client = httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url='http://testserver' ) factory = ClientFactory( config=ClientConfig( @@ -251,8 +251,8 @@ def jsonrpc_setup(agent_card, base_e2e_setup) -> ClientSetup: rpc_url='/', ) app = Starlette(routes=[*agent_card_routes, *jsonrpc_routes]) - httpx_client = httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url='http://testserver' + httpx_client = httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url='http://testserver' ) factory = ClientFactory( config=ClientConfig( diff --git a/tests/integration/test_samples_smoke.py b/tests/integration/test_samples_smoke.py index fcb49a003..37bea5ab3 100644 --- a/tests/integration/test_samples_smoke.py +++ b/tests/integration/test_samples_smoke.py @@ -14,7 +14,7 @@ from pathlib import Path from typing import TYPE_CHECKING -import httpx +import httpx2 import pytest import pytest_asyncio @@ -41,13 +41,13 @@ def _free_port() -> int: async def _wait_for_agent_card(url: str) -> None: deadline = asyncio.get_running_loop().time() + STARTUP_TIMEOUT_S - async with httpx.AsyncClient(timeout=2.0) as client: + async with httpx2.AsyncClient(timeout=2.0) as client: while asyncio.get_running_loop().time() < deadline: try: response = await client.get(url) if response.status_code == 200: return - except httpx.RequestError: + except httpx2.RequestError: pass await asyncio.sleep(0.2) raise TimeoutError(f'Agent did not become ready at {url}') diff --git a/tests/integration/test_stream_generator_cleanup.py b/tests/integration/test_stream_generator_cleanup.py index 3d9b1b171..d7dea885b 100644 --- a/tests/integration/test_stream_generator_cleanup.py +++ b/tests/integration/test_stream_generator_cleanup.py @@ -11,7 +11,7 @@ from typing import Any from uuid import uuid4 -import httpx +import httpx2 import pytest from a2a.client.base_client import BaseClient @@ -88,8 +88,8 @@ def client(): ) return ClientFactory( config=ClientConfig( - httpx_client=httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), + httpx_client=httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url='http://test', ) ) diff --git a/tests/integration/test_tenant.py b/tests/integration/test_tenant.py index 64038f1e9..922da5b9d 100644 --- a/tests/integration/test_tenant.py +++ b/tests/integration/test_tenant.py @@ -1,6 +1,6 @@ from unittest.mock import AsyncMock, MagicMock, patch -import httpx +import httpx2 import pytest from a2a.client import ClientConfig, ClientFactory @@ -19,7 +19,7 @@ Task, ) from a2a.utils.constants import TransportProtocol -from httpx import ASGITransport, AsyncClient +from httpx2 import ASGITransport, AsyncClient from starlette.applications import Starlette @@ -49,7 +49,7 @@ def agent_card(self): @pytest.mark.asyncio async def test_tenant_decorator_rest(self, agent_card): - mock_httpx = AsyncMock(spec=httpx.AsyncClient) + mock_httpx = AsyncMock(spec=httpx2.AsyncClient) mock_httpx.is_closed = False mock_httpx.build_request.return_value = MagicMock() mock_httpx.send.return_value = MagicMock( @@ -86,7 +86,7 @@ async def test_tenant_decorator_rest(self, agent_card): @pytest.mark.asyncio async def test_tenant_decorator_jsonrpc(self, agent_card): - mock_httpx = AsyncMock(spec=httpx.AsyncClient) + mock_httpx = AsyncMock(spec=httpx2.AsyncClient) mock_httpx.is_closed = False mock_httpx.build_request.return_value = MagicMock() mock_httpx.send.return_value = MagicMock( @@ -142,7 +142,7 @@ async def test_tenant_decorator_grpc(self, agent_card): @pytest.mark.asyncio async def test_tenant_decorator_explicit_override(self, agent_card): - mock_httpx = AsyncMock(spec=httpx.AsyncClient) + mock_httpx = AsyncMock(spec=httpx2.AsyncClient) mock_httpx.is_closed = False mock_httpx.build_request.return_value = MagicMock() mock_httpx.send.return_value = MagicMock( diff --git a/tests/server/request_handlers/test_default_request_handler_v2.py b/tests/server/request_handlers/test_default_request_handler_v2.py index b30fdc695..2d6c70a8b 100644 --- a/tests/server/request_handlers/test_default_request_handler_v2.py +++ b/tests/server/request_handlers/test_default_request_handler_v2.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch -import httpx +import httpx2 import pytest from a2a.auth.user import UnauthenticatedUser, User @@ -2624,7 +2624,7 @@ async def test_push_config_store_failure_does_not_fail_task(): task_store=task_store, push_config_store=FlakyPushConfigStore(), push_sender=BasePushNotificationSender( - httpx_client=AsyncMock(spec=httpx.AsyncClient), + httpx_client=AsyncMock(spec=httpx2.AsyncClient), config_store=FlakyPushConfigStore(), ), agent_card=create_default_agent_card(), diff --git a/tests/server/tasks/test_inmemory_push_notifications.py b/tests/server/tasks/test_inmemory_push_notifications.py index 8acae7d9d..b6b022d2e 100644 --- a/tests/server/tasks/test_inmemory_push_notifications.py +++ b/tests/server/tasks/test_inmemory_push_notifications.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch -import httpx +import httpx2 from a2a.auth.user import User from a2a.server.context import ServerCallContext @@ -94,7 +94,7 @@ def _set_info_in_thread( class TestInMemoryPushNotifier(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: - self.mock_httpx_client = AsyncMock(spec=httpx.AsyncClient) + self.mock_httpx_client = AsyncMock(spec=httpx2.AsyncClient) self.config_store = InMemoryPushNotificationConfigStore() self.notifier = BasePushNotificationSender( httpx_client=self.mock_httpx_client, @@ -248,7 +248,7 @@ async def test_send_notification_success(self) -> None: await self.config_store.set_info(task_id, config, MINIMAL_CALL_CONTEXT) # Mock the post call to simulate success - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 200 self.mock_httpx_client.post.return_value = mock_response @@ -275,7 +275,7 @@ async def test_send_notification_with_token_success(self) -> None: await self.config_store.set_info(task_id, config, MINIMAL_CALL_CONTEXT) # Mock the post call to simulate success - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 200 self.mock_httpx_client.post.return_value = mock_response @@ -315,11 +315,11 @@ async def test_send_notification_http_status_error( await self.config_store.set_info(task_id, config, MINIMAL_CALL_CONTEXT) mock_response = MagicMock( - spec=httpx.Response + spec=httpx2.Response ) # Use MagicMock for status_code attribute mock_response.status_code = 404 mock_response.text = 'Not Found' - http_error = httpx.HTTPStatusError( + http_error = httpx2.HTTPStatusError( 'Not Found', request=MagicMock(), response=mock_response ) self.mock_httpx_client.post.side_effect = http_error @@ -344,7 +344,9 @@ async def test_send_notification_request_error( config = _create_sample_push_config(url='http://notify.me/req_error') await self.config_store.set_info(task_id, config, MINIMAL_CALL_CONTEXT) - request_error = httpx.RequestError('Network issue', request=MagicMock()) + request_error = httpx2.RequestError( + 'Network issue', request=MagicMock() + ) self.mock_httpx_client.post.side_effect = request_error await self.notifier.send_notification(task_id, task_data) @@ -373,7 +375,7 @@ async def test_send_notification_with_auth( # It only supports token-based auth via the token field await self.config_store.set_info(task_id, config, MINIMAL_CALL_CONTEXT) - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 200 self.mock_httpx_client.post.return_value = mock_response @@ -553,8 +555,8 @@ class TestPushNotificationDispatchAcrossOwners( """ def setUp(self) -> None: - self.mock_httpx_client = AsyncMock(spec=httpx.AsyncClient) - mock_response = AsyncMock(spec=httpx.Response) + self.mock_httpx_client = AsyncMock(spec=httpx2.AsyncClient) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 200 self.mock_httpx_client.post.return_value = mock_response diff --git a/tests/server/tasks/test_push_notification_sender.py b/tests/server/tasks/test_push_notification_sender.py index 4772581b7..1c02b5aa9 100644 --- a/tests/server/tasks/test_push_notification_sender.py +++ b/tests/server/tasks/test_push_notification_sender.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch -import httpx +import httpx2 from a2a.server.tasks.base_push_notification_sender import ( BasePushNotificationSender, @@ -46,7 +46,7 @@ def _create_sample_push_config( class TestBasePushNotificationSender(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: - self.mock_httpx_client = AsyncMock(spec=httpx.AsyncClient) + self.mock_httpx_client = AsyncMock(spec=httpx2.AsyncClient) self.mock_config_store = AsyncMock() self.sender = BasePushNotificationSender( httpx_client=self.mock_httpx_client, @@ -63,7 +63,7 @@ async def test_send_notification_success(self) -> None: config = _create_sample_push_config(url='http://notify.me/here') self.mock_config_store.get_info_for_dispatch.return_value = [config] - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 200 self.mock_httpx_client.post.return_value = mock_response @@ -89,7 +89,7 @@ async def test_send_notification_with_token_success(self) -> None: ) self.mock_config_store.get_info_for_dispatch.return_value = [config] - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 200 self.mock_httpx_client.post.return_value = mock_response @@ -114,7 +114,7 @@ async def _post_headers_for(self, **config_kwargs) -> dict[str, str] | None: _create_sample_push_config(**config_kwargs) ] self.mock_httpx_client.post.return_value = AsyncMock( - spec=httpx.Response, status_code=200 + spec=httpx2.Response, status_code=200 ) await self.sender.send_notification(task_data.id, task_data) @@ -206,10 +206,10 @@ async def test_send_notification_http_status_error( config = _create_sample_push_config(url='http://notify.me/http_error') self.mock_config_store.get_info_for_dispatch.return_value = [config] - mock_response = MagicMock(spec=httpx.Response) + mock_response = MagicMock(spec=httpx2.Response) mock_response.status_code = 404 mock_response.text = 'Not Found' - http_error = httpx.HTTPStatusError( + http_error = httpx2.HTTPStatusError( 'Not Found', request=MagicMock(), response=mock_response ) self.mock_httpx_client.post.side_effect = http_error @@ -240,7 +240,7 @@ async def test_send_notification_multiple_configs(self) -> None: config2, ] - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 200 self.mock_httpx_client.post.return_value = mock_response @@ -274,7 +274,7 @@ async def test_send_notification_status_update_event(self) -> None: config = _create_sample_push_config(url='http://notify.me/status') self.mock_config_store.get_info_for_dispatch.return_value = [config] - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 200 self.mock_httpx_client.post.return_value = mock_response @@ -298,7 +298,7 @@ async def test_send_notification_artifact_update_event(self) -> None: config = _create_sample_push_config(url='http://notify.me/artifact') self.mock_config_store.get_info_for_dispatch.return_value = [config] - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 200 self.mock_httpx_client.post.return_value = mock_response @@ -363,7 +363,7 @@ class TestPushUrlValidation(unittest.IsolatedAsyncioTestCase): push URLs must not reach non-public destinations.""" def setUp(self) -> None: - self.mock_httpx_client = AsyncMock(spec=httpx.AsyncClient) + self.mock_httpx_client = AsyncMock(spec=httpx2.AsyncClient) self.mock_config_store = AsyncMock() self.sender = BasePushNotificationSender( httpx_client=self.mock_httpx_client, @@ -375,7 +375,7 @@ async def _dispatch(self, url: str) -> None: task = _create_sample_task() config = _create_sample_push_config(url=url) self.mock_config_store.get_info_for_dispatch.return_value = [config] - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 200 self.mock_httpx_client.post.return_value = mock_response await self.sender.send_notification(task.id, task) @@ -441,7 +441,7 @@ async def test_default_hook_none_skips_validation(self) -> None: task = _create_sample_task() config = _create_sample_push_config(url='http://localhost:9000/hook') self.mock_config_store.get_info_for_dispatch.return_value = [config] - mock_response = AsyncMock(spec=httpx.Response) + mock_response = AsyncMock(spec=httpx2.Response) mock_response.status_code = 200 self.mock_httpx_client.post.return_value = mock_response await sender.send_notification(task.id, task) diff --git a/tests/utils/test_proto_utils.py b/tests/utils/test_proto_utils.py index 031aa2e89..7f97e4e2d 100644 --- a/tests/utils/test_proto_utils.py +++ b/tests/utils/test_proto_utils.py @@ -7,7 +7,7 @@ from typing import Any, cast -import httpx +import httpx2 import pytest from a2a.types.a2a_pb2 import ( @@ -273,7 +273,7 @@ def test_repeated_fields_parsing(self, query_string: str): def _message_to_rest_params(self, message: ProtobufMessage) -> QueryParams: """Converts a message to REST query parameters.""" rest_dict = MessageToDict(message) - return httpx.Request( + return httpx2.Request( 'GET', 'http://api.example.com', params=rest_dict ).url.params diff --git a/uv.lock b/uv.lock index ae9d2858a..a98c49348 100644 --- a/uv.lock +++ b/uv.lock @@ -2,9 +2,9 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version >= '3.11' and python_full_version < '3.13'", + "python_full_version >= '3.13'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", "python_full_version < '3.11'", ] @@ -21,7 +21,8 @@ dependencies = [ { name = "culsans", marker = "python_full_version < '3.13'" }, { name = "google-api-core" }, { name = "googleapis-common-protos" }, - { name = "httpx" }, + { name = "httpx2", version = "2.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "httpx2", version = "2.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "json-rpc" }, { name = "packaging" }, { name = "protobuf" }, @@ -95,7 +96,6 @@ dev = [ { name = "pytest-mock" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, - { name = "respx" }, { name = "rfc8785" }, { name = "ruff" }, { name = "trio" }, @@ -123,7 +123,7 @@ requires-dist = [ { name = "grpcio-reflection", marker = "extra == 'grpc'", specifier = ">=1.7.0" }, { name = "grpcio-tools", marker = "extra == 'all'", specifier = ">=1.60" }, { name = "grpcio-tools", marker = "extra == 'grpc'", specifier = ">=1.60" }, - { name = "httpx", specifier = ">=0.28.1" }, + { name = "httpx2", specifier = ">=2.14.0" }, { name = "json-rpc", specifier = ">=1.15.0" }, { name = "opentelemetry-api", marker = "extra == 'all'", specifier = ">=1.33.0" }, { name = "opentelemetry-api", marker = "extra == 'telemetry'", specifier = ">=1.33.0" }, @@ -164,7 +164,6 @@ dev = [ { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, - { name = "respx", specifier = ">=0.20.2" }, { name = "rfc8785", specifier = ">=0.1.4" }, { name = "ruff", specifier = ">=0.12.8" }, { name = "trio" }, @@ -1111,40 +1110,86 @@ wheels = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ - { name = "certifi" }, { name = "h11" }, + { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/ff/b53af1b49bb2cb5fa97c92215614b173ca5c1a99ded80e2d52f15a94fa6d/httpcore2-2.14.0.tar.gz", hash = "sha256:4f57a1a8249fe6af589d935cfd456f1d13bbdcb0f0fc72af9142368fc94811d7", size = 69096, upload-time = "2026-10-18T16:29:21.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/02/06/a8d6a8f90d11b76d9086242b6bb0ee4b503473bfc8be34c2d5b067275c22/httpcore2-2.14.0-py3-none-any.whl", hash = "sha256:4c4f64f4b544afeb778c42c0ffb5941b29e671157e90ed3f6dc787c4bca90cd1", size = 84291, upload-time = "2026-10-18T16:29:19.743Z" }, ] [[package]] -name = "httpx" -version = "0.28.1" +name = "httpcore2" +version = "2.16.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "(python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", +] dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/c2/ce417c397afa5181f0e3c6ff01a4d01639414e1adef08730d97571297151/httpcore2-2.16.0-py3-none-any.whl", hash = "sha256:5c4358319f2602d984ad93182c9fee5160f22e9da7babdda870a5e7c4e2dc34f", size = 85070, upload-time = "2026-10-21T07:09:42.802Z" }, +] + +[[package]] +name = "httpx2" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", version = "2.14.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'emscripten'" }, { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/67/58353f1848b4ba568e1ec22ea1e0d8e039f0bca5fde7e2d1f07739fd83e7/httpx2-2.14.0.tar.gz", hash = "sha256:47ad61a3c40282cd8d0dc96311531f00f5bfc71a066c6fe3b36d8f63fb9e4f6e", size = 102036, upload-time = "2026-10-18T16:29:22.627Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/69/5a/469616aa8c2cbb0bb9dd67a3c18d07c01ab604cc0b236ee1c47d260c7f6e/httpx2-2.14.0-py3-none-any.whl", hash = "sha256:9d20c13a10bae1773a0be3b0f1e5beeb4bdd549bc3a2ba18efa32897c65403e4", size = 96901, upload-time = "2026-10-18T16:29:20.819Z" }, ] [[package]] -name = "httpx-sse" -version = "0.4.3" +name = "httpx2" +version = "2.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", +] +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", version = "2.16.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/73/5ca2cedf5e4e50423d1c258877865a6b62793633b561c58499f8aeb33b7e/httpx2-2.16.0.tar.gz", hash = "sha256:0d54c98128e8f2ff7fbbde0fca8c359e5f032edeaaaac443cdb7f31f685bcd7f", size = 103116, upload-time = "2026-10-21T07:09:46.348Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3d/2e4bc4fd1ef9770871669adb348d612a6e4786b303a23f9935b81c48dd60/httpx2-2.16.0-py3-none-any.whl", hash = "sha256:fd2c5d7909e8d86fe92d53020c310ca0e4e431b838c207c5754b9bbf0308d49a", size = 97880, upload-time = "2026-10-21T07:09:44.01Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -1158,11 +1203,11 @@ wheels = [ [[package]] name = "idna" -version = "3.15" +version = "3.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] [[package]] @@ -1183,7 +1228,6 @@ dependencies = [ { name = "fastapi" }, { name = "grpcio" }, { name = "grpcio-tools" }, - { name = "httpx-sse" }, { name = "packaging" }, { name = "protobuf" }, { name = "sse-starlette" }, @@ -1196,7 +1240,6 @@ requires-dist = [ { name = "fastapi" }, { name = "grpcio" }, { name = "grpcio-tools" }, - { name = "httpx-sse" }, { name = "packaging" }, { name = "protobuf" }, { name = "sse-starlette" }, @@ -1825,18 +1868,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] -[[package]] -name = "respx" -version = "0.23.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, -] - [[package]] name = "rfc8785" version = "0.1.4" @@ -2092,6 +2123,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bb/4a/2e5583e544bc437d5e8e54b47db87430df9031b29b48d17f26d129fa60c0/trove_classifiers-2026.1.14.14-py3-none-any.whl", hash = "sha256:1f9553927f18d0513d8e5ff80ab8980b8202ce37ecae0e3274ed2ef11880e74d", size = 14197, upload-time = "2026-01-14T14:54:49.067Z" }, ] +[[package]] +name = "truststore" +version = "0.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" }, +] + [[package]] name = "ty" version = "0.0.44" From d65c7fa0c062a6a22c07eb65344472563482781c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Ra=C4=8Dinsk=C3=BD?= Date: Thu, 22 Oct 2026 10:42:38 +0200 Subject: [PATCH 2/2] test: old version revert --- tests/client/test_auth_interceptor.py | 66 +++++++++++-------- .../cross_version/client_server/client_0_3.py | 6 +- 2 files changed, 42 insertions(+), 30 deletions(-) diff --git a/tests/client/test_auth_interceptor.py b/tests/client/test_auth_interceptor.py index 58d03d93a..18eba3d58 100644 --- a/tests/client/test_auth_interceptor.py +++ b/tests/client/test_auth_interceptor.py @@ -1,12 +1,13 @@ # ruff: noqa: INP001, S106 import json -from collections.abc import Callable +from collections.abc import AsyncIterator, Callable from dataclasses import dataclass from typing import Any import httpx2 import pytest +import pytest_asyncio from a2a.client import ( AuthInterceptor, @@ -66,18 +67,6 @@ def build_message() -> Message: ) -def build_capturing_client() -> tuple[httpx2.AsyncClient, list[httpx2.Request]]: - """Builds an HTTP client that records requests and replies with a success.""" - requests: list[httpx2.Request] = [] - - def handler(request: httpx2.Request) -> httpx2.Response: - requests.append(request) - return build_success_response(request) - - client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) - return client, requests - - async def send_message( client: Client, requests: list[httpx2.Request], @@ -96,6 +85,28 @@ async def send_message( return requests[-1] +@pytest.fixture +def requests() -> list[httpx2.Request]: + """Requests received by the mocked HTTP transport, in order.""" + return [] + + +@pytest_asyncio.fixture +async def http_client( + requests: list[httpx2.Request], +) -> AsyncIterator[httpx2.AsyncClient]: + """HTTP client that records requests and replies with a JSON-RPC success.""" + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return build_success_response(request) + + async with httpx2.AsyncClient( + transport=httpx2.MockTransport(handler) + ) as client: + yield client + + @pytest.fixture def store(): store = InMemoryContextCredentialStore() @@ -255,7 +266,10 @@ class AuthTestCase: [api_key_test_case, oauth2_test_case, oidc_test_case, bearer_test_case], ) async def test_auth_interceptor_variants( - test_case: AuthTestCase, store: InMemoryContextCredentialStore + test_case: AuthTestCase, + store: InMemoryContextCredentialStore, + http_client: httpx2.AsyncClient, + requests: list[httpx2.Request], ) -> None: """Parametrized test verifying that AuthInterceptor correctly attaches credentials based on the defined security scheme in the AgentCard.""" await store.set_credentials( @@ -285,19 +299,17 @@ async def test_auth_interceptor_variants( }, ) - http_client, requests = build_capturing_client() - async with http_client: - config = ClientConfig( - httpx_client=http_client, - supported_protocol_bindings=[TransportProtocol.JSONRPC], - ) - factory = ClientFactory(config) - client = factory.create(agent_card, interceptors=[auth_interceptor]) - - request = await send_message(client, requests, test_case.session_id) - assert request.headers[ - test_case.expected_header_key - ] == test_case.expected_header_value_func(test_case.credential) + config = ClientConfig( + httpx_client=http_client, + supported_protocol_bindings=[TransportProtocol.JSONRPC], + ) + factory = ClientFactory(config) + client = factory.create(agent_card, interceptors=[auth_interceptor]) + + request = await send_message(client, requests, test_case.session_id) + assert request.headers[ + test_case.expected_header_key + ] == test_case.expected_header_value_func(test_case.credential) @pytest.mark.asyncio diff --git a/tests/integration/cross_version/client_server/client_0_3.py b/tests/integration/cross_version/client_server/client_0_3.py index ed67e2789..70a3263bd 100644 --- a/tests/integration/cross_version/client_server/client_0_3.py +++ b/tests/integration/cross_version/client_server/client_0_3.py @@ -6,7 +6,7 @@ from uuid import uuid4 import grpc -import httpx2 +import httpx from a2a.client import ClientConfig, ClientFactory from a2a.types import ( @@ -75,7 +75,7 @@ async def test_send_message_stream(client): async def test_send_message_sync(url, protocol_enum): print('Testing send_message (synchronous)...') config = ClientConfig() - config.httpx_client = httpx2.AsyncClient(timeout=30.0) + config.httpx_client = httpx.AsyncClient(timeout=30.0) config.grpc_channel_factory = grpc.aio.insecure_channel config.supported_transports = [protocol_enum] config.streaming = False @@ -238,7 +238,7 @@ async def run_client(url: str, protocol: str): protocol_enum = protocol_enum_map[protocol] config = ClientConfig() - config.httpx_client = httpx2.AsyncClient(timeout=30.0) + config.httpx_client = httpx.AsyncClient(timeout=30.0) config.grpc_channel_factory = grpc.aio.insecure_channel config.supported_transports = [protocol_enum] config.streaming = True