Skip to content

Commit 27e5200

Browse files
committed
test: enhance HTTP error handling tests and add streaming error tests
1 parent 89595f0 commit 27e5200

File tree

2 files changed

+193
-7
lines changed

2 files changed

+193
-7
lines changed

tests/client/test_errors.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@ def test_instantiation(self):
2727
assert isinstance(error, A2AClientError)
2828
assert error.status_code == 404
2929
assert error.message == 'Not Found'
30+
assert error.body is None
31+
assert error.headers == {}
3032

3133
def test_message_formatting(self):
3234
"""Test that the error message is formatted correctly."""
3335
error = A2AClientHTTPError(500, 'Internal Server Error')
34-
assert str(error) == 'HTTP Error 500: Internal Server Error'
36+
assert str(error) == 'HTTP 500 - Internal Server Error'
3537

3638
def test_inheritance(self):
3739
"""Test that A2AClientHTTPError inherits from A2AClientError."""
@@ -43,7 +45,7 @@ def test_with_empty_message(self):
4345
error = A2AClientHTTPError(403, '')
4446
assert error.status_code == 403
4547
assert error.message == ''
46-
assert str(error) == 'HTTP Error 403: '
48+
assert str(error) == 'HTTP 403 - '
4749

4850
def test_with_various_status_codes(self):
4951
"""Test with different HTTP status codes."""
@@ -62,7 +64,7 @@ def test_with_various_status_codes(self):
6264
error = A2AClientHTTPError(status_code, message)
6365
assert error.status_code == status_code
6466
assert error.message == message
65-
assert str(error) == f'HTTP Error {status_code}: {message}'
67+
assert str(error) == f'HTTP {status_code} - {message}'
6668

6769

6870
class TestA2AClientJSONError:
@@ -148,7 +150,7 @@ def test_raising_http_error(self):
148150
error = excinfo.value
149151
assert error.status_code == 429
150152
assert error.message == 'Too Many Requests'
151-
assert str(error) == 'HTTP Error 429: Too Many Requests'
153+
assert str(error) == 'HTTP 429 - Too Many Requests'
152154

153155
def test_raising_json_error(self):
154156
"""Test raising a JSON error and checking its properties."""
@@ -173,9 +175,9 @@ def test_raising_base_error(self):
173175
@pytest.mark.parametrize(
174176
'status_code,message,expected',
175177
[
176-
(400, 'Bad Request', 'HTTP Error 400: Bad Request'),
177-
(404, 'Not Found', 'HTTP Error 404: Not Found'),
178-
(500, 'Server Error', 'HTTP Error 500: Server Error'),
178+
(400, 'Bad Request', 'HTTP 400 - Bad Request'),
179+
(404, 'Not Found', 'HTTP 404 - Not Found'),
180+
(500, 'Server Error', 'HTTP 500 - Server Error'),
179181
],
180182
)
181183
def test_http_error_parametrized(status_code, message, expected):
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""Tests for a2a.client.errors module."""
2+
3+
from collections.abc import AsyncIterator
4+
from contextlib import asynccontextmanager
5+
from dataclasses import dataclass
6+
from typing import Any
7+
from unittest.mock import AsyncMock, patch
8+
9+
import httpx
10+
import pytest
11+
12+
from a2a.client import create_text_message_object
13+
from a2a.client.errors import A2AClientHTTPError
14+
from a2a.client.transports.rest import RestTransport
15+
from a2a.types import MessageSendParams
16+
17+
18+
@dataclass
19+
class DummyServerSentEvent:
20+
data: str
21+
22+
23+
class MockEventSource:
24+
def __init__(
25+
self,
26+
response: httpx.Response,
27+
events: list[Any] | None = None,
28+
error: Exception | None = None,
29+
):
30+
self.response = response
31+
self._events = events or []
32+
self._error = error
33+
34+
async def __aenter__(self) -> 'MockEventSource':
35+
return self
36+
37+
async def __aexit__(self, exc_type, exc, tb) -> None:
38+
return None
39+
40+
async def aiter_sse(self) -> AsyncIterator[Any]:
41+
if self._error:
42+
raise self._error
43+
for event in self._events:
44+
yield event
45+
46+
47+
def make_response(
48+
status: int,
49+
*,
50+
json_body: dict[str, Any] | None = None,
51+
text_body: str | None = None,
52+
headers: dict[str, str] | None = None,
53+
) -> httpx.Response:
54+
request = httpx.Request('POST', 'https://api.example.com/v1/message:stream')
55+
if json_body is not None:
56+
response = httpx.Response(
57+
status,
58+
json=json_body,
59+
headers=headers,
60+
request=request,
61+
)
62+
else:
63+
response = httpx.Response(
64+
status,
65+
content=text_body.encode() if text_body else b'',
66+
headers=headers,
67+
request=request,
68+
)
69+
return response
70+
71+
72+
def make_transport() -> RestTransport:
73+
httpx_client = AsyncMock(spec=httpx.AsyncClient)
74+
transport = RestTransport(
75+
httpx_client=httpx_client, url='https://api.example.com'
76+
)
77+
transport._prepare_send_message = AsyncMock(return_value=({}, {}))
78+
return transport
79+
80+
81+
async def collect_stream(transport: RestTransport, params: MessageSendParams):
82+
return [item async for item in transport.send_message_streaming(params)]
83+
84+
85+
def patch_stream_context(event_source: MockEventSource):
86+
@asynccontextmanager
87+
async def fake_aconnect_sse(*_: Any, **__: Any):
88+
yield event_source
89+
90+
return patch(
91+
'a2a.client.transports.rest.aconnect_sse', new=fake_aconnect_sse
92+
)
93+
94+
95+
@pytest.mark.parametrize(
96+
('status', 'body', 'expected'),
97+
[
98+
(401, {'error': 'invalid_token'}, 'invalid_token'),
99+
(500, {'message': 'DB down'}, 'DB down'),
100+
(503, {'detail': 'Service unavailable'}, 'Service unavailable'),
101+
(
102+
404,
103+
{'title': 'Not Found', 'detail': 'No such task'},
104+
'Not Found: No such task',
105+
),
106+
],
107+
)
108+
@pytest.mark.asyncio
109+
async def test_streaming_surfaces_http_errors(
110+
status: int, body: dict[str, Any], expected: str
111+
):
112+
transport = make_transport()
113+
params = MessageSendParams(
114+
message=create_text_message_object(content='Hello')
115+
)
116+
response = make_response(status, json_body=body)
117+
event_source = MockEventSource(response)
118+
119+
with patch_stream_context(event_source), pytest.raises(
120+
A2AClientHTTPError
121+
) as exc_info:
122+
await collect_stream(transport, params)
123+
124+
error = exc_info.value
125+
assert error.status == status
126+
assert expected in error.message
127+
assert error.body is not None
128+
assert str(status) in str(error)
129+
130+
131+
@pytest.mark.asyncio
132+
async def test_streaming_rejects_wrong_content_type():
133+
transport = make_transport()
134+
params = MessageSendParams(
135+
message=create_text_message_object(content='Hello')
136+
)
137+
response = make_response(
138+
200,
139+
json_body={'message': 'not a stream'},
140+
headers={'content-type': 'application/json'},
141+
)
142+
event_source = MockEventSource(response)
143+
144+
with patch_stream_context(event_source), pytest.raises(
145+
A2AClientHTTPError
146+
) as exc_info:
147+
await collect_stream(transport, params)
148+
149+
error = exc_info.value
150+
assert error.status == 200
151+
assert 'Unexpected Content-Type' in error.message
152+
assert 'application/json' in error.message
153+
154+
155+
@pytest.mark.asyncio
156+
async def test_streaming_success_path_unchanged():
157+
transport = make_transport()
158+
params = MessageSendParams(
159+
message=create_text_message_object(content='Hello')
160+
)
161+
response = make_response(
162+
200,
163+
text_body='event-stream',
164+
headers={'content-type': 'text/event-stream'},
165+
)
166+
events = [DummyServerSentEvent(data='{"foo":"bar"}')]
167+
event_source = MockEventSource(response, events=events)
168+
169+
with (
170+
patch_stream_context(event_source),
171+
patch(
172+
'a2a.client.transports.rest.Parse',
173+
side_effect=lambda data, obj: obj,
174+
) as mock_parse,
175+
patch(
176+
'a2a.client.transports.rest.proto_utils.FromProto.stream_response',
177+
return_value={'result': 'ok'},
178+
) as mock_from_proto,
179+
):
180+
results = await collect_stream(transport, params)
181+
182+
assert results == [{'result': 'ok'}]
183+
mock_parse.assert_called()
184+
mock_from_proto.assert_called()

0 commit comments

Comments
 (0)