Skip to content

Commit 2acd838

Browse files
fix: Improve error handling for Timeout exceptions on REST and JSON-RPC clients (#690)
This PR standardizes timeout error handling across the JSON-RPC and REST clients. Previously, only the JSON-RPC client (in non-streaming mode) handled `ReadTimeout` exceptions, while streaming calls and the REST client catch them incorrectly. Updating both `JsonRpcTransport` and `RestTransport` to catch the base httpx.TimeoutException, all timeout types (Read, Connect, Write, Pool) are consistently caught and wrapped in A2AClientTimeoutError. This ensures consistent behavior for API consumers regardless of the transport (REST vs JSON-RPC) or mode (Streaming vs Non-Streaming) being used, preventing generic errors when network timeouts occur.
1 parent ae9dc88 commit 2acd838

4 files changed

Lines changed: 83 additions & 3 deletions

File tree

src/a2a/client/transports/jsonrpc.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ async def send_message_streaming(
184184
if isinstance(response.root, JSONRPCErrorResponse):
185185
raise A2AClientJSONRPCError(response.root)
186186
yield response.root.result
187+
except httpx.TimeoutException as e:
188+
raise A2AClientTimeoutError('Client Request timed out') from e
187189
except httpx.HTTPStatusError as e:
188190
raise A2AClientHTTPError(e.response.status_code, str(e)) from e
189191
except SSEError as e:
@@ -208,7 +210,7 @@ async def _send_request(
208210
)
209211
response.raise_for_status()
210212
return response.json()
211-
except httpx.ReadTimeout as e:
213+
except httpx.TimeoutException as e:
212214
raise A2AClientTimeoutError('Client Request timed out') from e
213215
except httpx.HTTPStatusError as e:
214216
raise A2AClientHTTPError(e.response.status_code, str(e)) from e
@@ -365,6 +367,8 @@ async def resubscribe(
365367
if isinstance(response.root, JSONRPCErrorResponse):
366368
raise A2AClientJSONRPCError(response.root)
367369
yield response.root.result
370+
except httpx.TimeoutException as e:
371+
raise A2AClientTimeoutError('Client Request timed out') from e
368372
except SSEError as e:
369373
raise A2AClientHTTPError(
370374
400, f'Invalid SSE response or protocol error: {e}'

src/a2a/client/transports/rest.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010
from httpx_sse import SSEError, aconnect_sse
1111

1212
from a2a.client.card_resolver import A2ACardResolver
13-
from a2a.client.errors import A2AClientHTTPError, A2AClientJSONError
13+
from a2a.client.errors import (
14+
A2AClientHTTPError,
15+
A2AClientJSONError,
16+
A2AClientTimeoutError,
17+
)
1418
from a2a.client.middleware import ClientCallContext, ClientCallInterceptor
1519
from a2a.client.transports.base import ClientTransport
1620
from a2a.extensions.common import update_extension_header
@@ -159,6 +163,8 @@ async def send_message_streaming(
159163
event = a2a_pb2.StreamResponse()
160164
Parse(sse.data, event)
161165
yield proto_utils.FromProto.stream_response(event)
166+
except httpx.TimeoutException as e:
167+
raise A2AClientTimeoutError('Client Request timed out') from e
162168
except httpx.HTTPStatusError as e:
163169
raise A2AClientHTTPError(e.response.status_code, str(e)) from e
164170
except SSEError as e:
@@ -177,6 +183,8 @@ async def _send_request(self, request: httpx.Request) -> dict[str, Any]:
177183
response = await self.httpx_client.send(request)
178184
response.raise_for_status()
179185
return response.json()
186+
except httpx.TimeoutException as e:
187+
raise A2AClientTimeoutError('Client Request timed out') from e
180188
except httpx.HTTPStatusError as e:
181189
raise A2AClientHTTPError(e.response.status_code, str(e)) from e
182190
except json.JSONDecodeError as e:
@@ -357,6 +365,8 @@ async def resubscribe(
357365
event = a2a_pb2.StreamResponse()
358366
Parse(sse.data, event)
359367
yield proto_utils.FromProto.stream_response(event)
368+
except httpx.TimeoutException as e:
369+
raise A2AClientTimeoutError('Client Request timed out') from e
360370
except SSEError as e:
361371
raise A2AClientHTTPError(
362372
400, f'Invalid SSE response or protocol error: {e}'

tests/client/transports/test_jsonrpc_client.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,38 @@ async def test_send_message_client_timeout(
599599

600600
assert 'Client Request timed out' in str(exc_info.value)
601601

602+
@pytest.mark.asyncio
603+
@patch('a2a.client.transports.jsonrpc.aconnect_sse')
604+
async def test_send_message_streaming_timeout(
605+
self,
606+
mock_aconnect_sse: AsyncMock,
607+
mock_httpx_client: AsyncMock,
608+
mock_agent_card: MagicMock,
609+
):
610+
client = JsonRpcTransport(
611+
httpx_client=mock_httpx_client, agent_card=mock_agent_card
612+
)
613+
params = MessageSendParams(
614+
message=create_text_message_object(content='Hello stream')
615+
)
616+
mock_event_source = AsyncMock(spec=EventSource)
617+
mock_event_source.response = MagicMock(spec=httpx.Response)
618+
mock_event_source.response.raise_for_status.return_value = None
619+
mock_event_source.aiter_sse.side_effect = httpx.TimeoutException(
620+
'Read timed out'
621+
)
622+
mock_aconnect_sse.return_value.__aenter__.return_value = (
623+
mock_event_source
624+
)
625+
626+
with pytest.raises(A2AClientTimeoutError) as exc_info:
627+
_ = [
628+
item
629+
async for item in client.send_message_streaming(request=params)
630+
]
631+
632+
assert 'Client Request timed out' in str(exc_info.value)
633+
602634
@pytest.mark.asyncio
603635
async def test_get_task_success(
604636
self, mock_httpx_client: AsyncMock, mock_agent_card: MagicMock

tests/client/transports/test_rest_client.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from httpx_sse import EventSource, ServerSentEvent
1010

1111
from a2a.client import create_text_message_object
12-
from a2a.client.errors import A2AClientHTTPError
12+
from a2a.client.errors import A2AClientHTTPError, A2AClientTimeoutError
1313
from a2a.client.transports.rest import RestTransport
1414
from a2a.extensions.common import HTTP_EXTENSION_HEADER
1515
from a2a.grpc import a2a_pb2
@@ -50,6 +50,40 @@ def _assert_extensions_header(mock_kwargs: dict, expected_extensions: set[str]):
5050
assert actual_extensions == expected_extensions
5151

5252

53+
class TestRestTransport:
54+
@pytest.mark.asyncio
55+
@patch('a2a.client.transports.rest.aconnect_sse')
56+
async def test_send_message_streaming_timeout(
57+
self,
58+
mock_aconnect_sse: AsyncMock,
59+
mock_httpx_client: AsyncMock,
60+
mock_agent_card: MagicMock,
61+
):
62+
client = RestTransport(
63+
httpx_client=mock_httpx_client, agent_card=mock_agent_card
64+
)
65+
params = MessageSendParams(
66+
message=create_text_message_object(content='Hello stream')
67+
)
68+
mock_event_source = AsyncMock(spec=EventSource)
69+
mock_event_source.response = MagicMock(spec=httpx.Response)
70+
mock_event_source.response.raise_for_status.return_value = None
71+
mock_event_source.aiter_sse.side_effect = httpx.TimeoutException(
72+
'Read timed out'
73+
)
74+
mock_aconnect_sse.return_value.__aenter__.return_value = (
75+
mock_event_source
76+
)
77+
78+
with pytest.raises(A2AClientTimeoutError) as exc_info:
79+
_ = [
80+
item
81+
async for item in client.send_message_streaming(request=params)
82+
]
83+
84+
assert 'Client Request timed out' in str(exc_info.value)
85+
86+
5387
class TestRestTransportExtensions:
5488
@pytest.mark.asyncio
5589
async def test_send_message_with_default_extensions(

0 commit comments

Comments
 (0)