Skip to content

Commit 0d82ab9

Browse files
refactor(http): eliminate third-party httpx-sse dependency (#1132)
Summary Removed the third-party dependency `httpx-sse` to reduce the SDK's attack surface. Replaced it with a native, lightweight Server-Sent Events (SSE) streaming parser inside the HTTP transport helpers. Changes - **Dependencies:** Removed `httpx-sse` from `pyproject.toml` and updated `uv.lock`. - **Client (http_helpers.py):** Replaced `EventSource.aiter_sse()` with a local `parse_sse_stream` generator that parses streaming line blocks and yields typed event payloads. - **Tests:** Refactored `test_rest_client.py` and `test_jsonrpc_client.py` to mock standard `Response` line streaming instead of the third-party `EventSource` object. Verification Ran tests and linters locally with `httpx-sse` uninstalled: ```bash ./scripts/lint.sh uv run pytest tests/client/transports/test_rest_client.py tests/client/transports/test_jsonrpc_client.py ``` Status: 72 tests passed, linter green. --------- Co-authored-by: Ivan Shymko <ishymko@google.com>
1 parent 723880e commit 0d82ab9

6 files changed

Lines changed: 184 additions & 132 deletions

File tree

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ requires-python = ">=3.10"
99
keywords = ["A2A", "A2A SDK", "A2A Protocol", "Agent2Agent", "Agent 2 Agent"]
1010
dependencies = [
1111
"httpx>=0.28.1",
12-
"httpx-sse>=0.4.0",
1312
"pydantic>=2.11.3",
1413
"protobuf>=5.29.5,<7",
1514
"google-api-core>=1.26.0",

src/a2a/client/transports/http_helpers.py

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66

77
import httpx
88

9-
from httpx_sse import EventSource, SSEError
10-
119
from a2a.client.client import ClientCallContext
1210
from a2a.client.errors import A2AClientError, A2AClientTimeoutError
1311

@@ -36,10 +34,6 @@ def handle_http_exceptions(
3634
if status_error_handler:
3735
status_error_handler(e)
3836
raise A2AClientError(f'HTTP Error {e.response.status_code}: {e}') from e
39-
except SSEError as e:
40-
raise A2AClientError(
41-
f'Invalid SSE response or protocol error: {e}'
42-
) from e
4337
except httpx.RequestError as e:
4438
raise A2AClientError(f'Network communication error: {e}') from e
4539
except json.JSONDecodeError as e:
@@ -69,6 +63,46 @@ async def send_http_request(
6963
return response.json()
7064

7165

66+
async def parse_sse_stream(
67+
response: httpx.Response,
68+
) -> AsyncGenerator[tuple[str, str], None]:
69+
"""Yields (event_name, data) from a streaming httpx.Response.
70+
71+
Conforms to the W3C Server-Sent Events specification.
72+
"""
73+
event_name = 'message'
74+
payload_chunks: list[str] = []
75+
76+
async for line in response.aiter_lines():
77+
raw_line = line.rstrip('\r\n')
78+
79+
# Empty line denotes the completion of the current event block
80+
if not raw_line:
81+
if payload_chunks:
82+
yield event_name, '\n'.join(payload_chunks)
83+
event_name = 'message'
84+
payload_chunks = []
85+
continue
86+
87+
# Ignore comment lines
88+
if raw_line.startswith(':'):
89+
continue
90+
91+
# Split key and value by first colon
92+
parts = raw_line.split(':', 1)
93+
key = parts[0]
94+
val = parts[1] if len(parts) > 1 else ''
95+
96+
# Strip a single optional leading space
97+
if val.startswith(' '):
98+
val = val[1:]
99+
100+
if key == 'event':
101+
event_name = val
102+
elif key == 'data':
103+
payload_chunks.append(val)
104+
105+
72106
async def send_http_stream_request(
73107
httpx_client: httpx.AsyncClient,
74108
method: str,
@@ -94,40 +128,44 @@ async def send_http_stream_request(
94128
with handle_http_exceptions(status_error_handler):
95129
async with _SSEEventSource(
96130
httpx_client, method, url, **kwargs
97-
) as event_source:
131+
) as response:
98132
try:
99-
event_source.response.raise_for_status()
133+
response.raise_for_status()
100134
except httpx.HTTPStatusError as e:
101135
# Read upfront streaming error content immediately, otherwise lower-level handlers
102136
# (e.g. response.json()) crash with 'ResponseNotRead' Access errors.
103-
await event_source.response.aread()
137+
await response.aread()
104138
raise e
105139

106140
# If the response is not a stream, read it standardly (e.g., upfront JSON-RPC error payload)
107-
if 'text/event-stream' not in event_source.response.headers.get(
141+
if 'text/event-stream' not in response.headers.get(
108142
'content-type', ''
109143
):
110-
content = await event_source.response.aread()
144+
content = await response.aread()
111145
yield content.decode('utf-8')
112146
return
113147

114-
async for sse in event_source.aiter_sse():
115-
if not sse.data:
148+
async for event_name, data in parse_sse_stream(response):
149+
if not data:
116150
continue
117-
if sse.event == 'error':
118-
sse_error_handler(sse.data)
119-
yield sse.data
151+
if event_name == 'error':
152+
sse_error_handler(data)
153+
yield data
120154

121155

122156
class _SSEEventSource:
123-
"""Class-based replacement for ``httpx_sse.aconnect_sse``.
124-
125-
``aconnect_sse`` is an ``@asynccontextmanager`` whose internal async
126-
generator gets tracked by the event loop. When the enclosing async
127-
generator is abandoned, the event loop's generator cleanup collides
128-
with the cascading cleanup — see https://bugs.python.org/issue38559.
129-
130-
Plain ``__aenter__``/``__aexit__`` coroutines avoid this entirely.
157+
"""Class-based context manager for managing streaming HTTP connections.
158+
159+
Using a class with `__aenter__` and `__aexit__` instead of an
160+
`@asynccontextmanager` decorated function prevents event loop finalization
161+
crashes (specifically `RuntimeError: GeneratorExit thrown into an active
162+
generator`).
163+
164+
This error occurs when an outer async generator (e.g., streaming reader)
165+
is abandoned early, causing the Python event loop to concurrently throw
166+
GeneratorExit into the nested context manager's suspended generator.
167+
Coroutine-based context managers are not async generators and avoid this
168+
collision (see https://bugs.python.org/issue38559).
131169
"""
132170

133171
def __init__(
@@ -146,9 +184,9 @@ def __init__(
146184
self._client = client
147185
self._response: httpx.Response | None = None
148186

149-
async def __aenter__(self) -> EventSource:
187+
async def __aenter__(self) -> httpx.Response:
150188
self._response = await self._client.send(self._request, stream=True)
151-
return EventSource(self._response)
189+
return self._response
152190

153191
async def __aexit__(self, *args: object) -> None:
154192
if self._response is not None:
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import httpx
2+
import pytest
3+
4+
from a2a.client.errors import A2AClientError
5+
from a2a.client.transports.http_helpers import (
6+
_default_sse_error_handler,
7+
parse_sse_stream,
8+
send_http_stream_request,
9+
)
10+
11+
12+
def test_default_sse_error_handler():
13+
with pytest.raises(
14+
A2AClientError, match='SSE stream error event received: error_msg'
15+
):
16+
_default_sse_error_handler('error_msg')
17+
18+
19+
@pytest.mark.asyncio
20+
async def test_parse_sse_stream_edge_cases():
21+
async def mock_aiter_lines():
22+
yield ': comment line (should be ignored)\n'
23+
yield 'event: custom_event\n'
24+
yield 'data: hello\n'
25+
yield 'data: world\n'
26+
yield '\n'
27+
yield '\n'
28+
yield 'data: \n'
29+
yield '\n'
30+
31+
response = httpx.Response(200)
32+
response.aiter_lines = mock_aiter_lines # type: ignore
33+
34+
events = [e async for e in parse_sse_stream(response)]
35+
assert events == [
36+
('custom_event', ' hello\nworld'),
37+
('message', ''),
38+
]
39+
40+
41+
@pytest.mark.asyncio
42+
async def test_send_http_stream_request_non_sse(mocker):
43+
client = httpx.AsyncClient()
44+
request = httpx.Request('GET', 'http://test')
45+
response = httpx.Response(
46+
200,
47+
headers={'Content-Type': 'application/json'},
48+
content=b'plain error response',
49+
request=request,
50+
)
51+
52+
mocker.patch(
53+
'a2a.client.transports.http_helpers._SSEEventSource.__aenter__',
54+
return_value=response,
55+
)
56+
mocker.patch(
57+
'a2a.client.transports.http_helpers._SSEEventSource.__aexit__',
58+
return_value=None,
59+
)
60+
61+
chunks = [
62+
c async for c in send_http_stream_request(client, 'GET', 'http://test')
63+
]
64+
assert chunks == ['plain error response']

tests/client/transports/test_jsonrpc_client.py

Lines changed: 35 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44

5+
from collections.abc import AsyncGenerator
56
from unittest.mock import AsyncMock, MagicMock, patch
67
from uuid import uuid4
78

@@ -31,7 +32,14 @@
3132
)
3233
from a2a.utils.errors import JSON_RPC_ERROR_CODE_MAP
3334
from google.protobuf import json_format
34-
from httpx_sse import EventSource, SSEError
35+
36+
37+
async def async_iterable_from_list(
38+
items: list[str],
39+
) -> AsyncGenerator[str, None]:
40+
"""Helper to create an async iterable from a list."""
41+
for item in items:
42+
yield item
3543

3644

3745
@pytest.fixture
@@ -439,17 +447,17 @@ async def test_send_message_streaming_sse_error(
439447
transport: JsonRpcTransport,
440448
):
441449
request = create_send_message_request()
442-
mock_event_source = AsyncMock()
443-
mock_event_source.response.raise_for_status = MagicMock()
444-
mock_event_source.response.headers = {
445-
'content-type': 'text/event-stream'
446-
}
447-
mock_event_source.aiter_sse = MagicMock(
448-
side_effect=SSEError('Simulated SSE error')
449-
)
450-
mock_aconnect_sse.return_value.__aenter__.return_value = (
451-
mock_event_source
450+
mock_response = AsyncMock(spec=httpx.Response)
451+
mock_response.raise_for_status = MagicMock()
452+
mock_response.headers = {'content-type': 'text/event-stream'}
453+
mock_response.aiter_lines.return_value = async_iterable_from_list(
454+
[
455+
'event: error',
456+
'data: Simulated SSE error',
457+
'',
458+
]
452459
)
460+
mock_aconnect_sse.return_value.__aenter__.return_value = mock_response
453461

454462
with pytest.raises(A2AClientError):
455463
async for _ in transport.send_message_streaming(request):
@@ -463,19 +471,13 @@ async def test_send_message_streaming_request_error(
463471
transport: JsonRpcTransport,
464472
):
465473
request = create_send_message_request()
466-
mock_event_source = AsyncMock()
467-
mock_event_source.response.raise_for_status = MagicMock()
468-
mock_event_source.response.headers = {
469-
'content-type': 'text/event-stream'
470-
}
471-
mock_event_source.aiter_sse = MagicMock(
472-
side_effect=httpx.RequestError(
473-
'Simulated request error', request=MagicMock()
474-
)
475-
)
476-
mock_aconnect_sse.return_value.__aenter__.return_value = (
477-
mock_event_source
474+
mock_response = AsyncMock(spec=httpx.Response)
475+
mock_response.raise_for_status = MagicMock()
476+
mock_response.headers = {'content-type': 'text/event-stream'}
477+
mock_response.aiter_lines.side_effect = httpx.RequestError(
478+
'Simulated request error', request=MagicMock()
478479
)
480+
mock_aconnect_sse.return_value.__aenter__.return_value = mock_response
479481

480482
with pytest.raises(A2AClientError):
481483
async for _ in transport.send_message_streaming(request):
@@ -489,17 +491,13 @@ async def test_send_message_streaming_timeout(
489491
transport: JsonRpcTransport,
490492
):
491493
request = create_send_message_request()
492-
mock_event_source = AsyncMock()
493-
mock_event_source.response.raise_for_status = MagicMock()
494-
mock_event_source.response.headers = {
495-
'content-type': 'text/event-stream'
496-
}
497-
mock_event_source.aiter_sse = MagicMock(
498-
side_effect=httpx.TimeoutException('Timeout')
499-
)
500-
mock_aconnect_sse.return_value.__aenter__.return_value = (
501-
mock_event_source
494+
mock_response = AsyncMock(spec=httpx.Response)
495+
mock_response.raise_for_status = MagicMock()
496+
mock_response.headers = {'content-type': 'text/event-stream'}
497+
mock_response.aiter_lines.side_effect = httpx.TimeoutException(
498+
'Timeout'
502499
)
500+
mock_aconnect_sse.return_value.__aenter__.return_value = mock_response
503501

504502
with pytest.raises(A2AClientError, match='timed out'):
505503
async for _ in transport.send_message_streaming(request):
@@ -574,24 +572,16 @@ async def test_send_message_streaming_server_error_propagates(
574572
)
575573
request = create_send_message_request(text='Error stream')
576574

577-
mock_event_source = AsyncMock(spec=EventSource)
578-
mock_response = MagicMock(spec=httpx.Response)
575+
mock_response = AsyncMock(spec=httpx.Response)
579576
mock_response.status_code = 403
577+
mock_response.headers = {'content-type': 'text/event-stream'}
580578
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
581579
'Forbidden',
582580
request=httpx.Request('POST', 'http://test.url'),
583581
response=mock_response,
584582
)
585-
mock_event_source.response = mock_response
586-
587-
async def empty_aiter():
588-
if False:
589-
yield
590-
591-
mock_event_source.aiter_sse = MagicMock(return_value=empty_aiter())
592-
mock_aconnect_sse.return_value.__aenter__.return_value = (
593-
mock_event_source
594-
)
583+
mock_response.aiter_lines.return_value = async_iterable_from_list([])
584+
mock_aconnect_sse.return_value.__aenter__.return_value = mock_response
595585

596586
with pytest.raises(A2AClientError) as exc_info:
597587
async for _ in client.send_message_streaming(request=request):

0 commit comments

Comments
 (0)