Skip to content

Commit 03a96b6

Browse files
OmarAlJarrahclaude
andcommitted
fix(transport): classify timeouts per phase and map streamed body errors
Three adapter inconsistencies in how a request-versus-response failure is reported, which matters because the retry policy must never blind-retry a non-idempotent request whose response may already be in flight: - The aiohttp client configured only a total timeout, so a connect-phase stall raised a bare TimeoutError indistinguishable from a read timeout and was misclassified as a response error. Configure sock_connect/sock_read so connect raises ConnectionTimeoutError (request error) and read raises SocketTimeoutError (response error), matching the per-operation timeouts the other adapters already use. - The requests client did not wrap response-body streaming, so a mid-body ChunkedEncodingError, connection drop, or read timeout escaped as the raw library exception. Map a body-read Timeout to ServiceResponseTimeoutError and any other RequestException to ServiceResponseError. - Document why the asyncio reference client deliberately classifies a post-connect OSError as a response error rather than the request error the other adapters use for their generic transport bucket. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 38eca9c commit 03a96b6

5 files changed

Lines changed: 149 additions & 19 deletions

File tree

packages/dexpace-sdk-http-aiohttp/src/dexpace/sdk/http/aiohttp/client.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,10 @@ class AiohttpHttpClient:
5353
components; the caller is then responsible for closing it.
5454
5555
Attributes:
56-
timeout: Total request timeout in seconds. Applied via
57-
``aiohttp.ClientTimeout(total=...)``. ``None`` disables the
56+
timeout: Per-phase request timeout in seconds, applied to both the
57+
connect and the socket-read phases via
58+
``aiohttp.ClientTimeout(sock_connect=..., sock_read=...)`` so the
59+
two phases raise distinguishable exceptions. ``None`` disables the
5860
timeout entirely (not recommended).
5961
"""
6062

@@ -77,8 +79,15 @@ async def execute(self, request: Request) -> AsyncResponse:
7779
if self._closed:
7880
raise ServiceRequestError("AiohttpHttpClient is closed")
7981
session = await self._ensure_session()
82+
# Per-phase budgets (not a single ``total=``) so aiohttp raises the
83+
# distinguishable ``ConnectionTimeoutError`` for a connect-phase stall
84+
# and ``SocketTimeoutError`` for a read-phase stall. This keeps the
85+
# connect -> request-error / read -> response-error split consistent
86+
# with the other transports, which all use per-operation timeouts.
8087
timeout_cfg = (
81-
aiohttp.ClientTimeout(total=self.timeout) if self.timeout is not None else None
88+
aiohttp.ClientTimeout(sock_connect=self.timeout, sock_read=self.timeout)
89+
if self.timeout is not None
90+
else None
8291
)
8392
data = _payload(request.body)
8493
try:

packages/dexpace-sdk-http-aiohttp/tests/test_aiohttp_client.py

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ async def test_content_length_extracted_from_response(base_url: str) -> None:
175175
assert response.body.content_length() > 0
176176

177177

178-
# ----------------------------------------------------------- unknown status (M21)
178+
# ----------------------------------------------------------------- unknown status
179179

180180

181181
class _FakeAioResponse:
@@ -201,7 +201,7 @@ def test_unknown_status_releases_connection_and_raises() -> None:
201201
assert "520" in str(exc_info.value)
202202

203203

204-
# ----------------------------------------------------------- post-close (M22)
204+
# ----------------------------------------------------------------- post-close
205205

206206

207207
async def test_execute_after_aclose_raises() -> None:
@@ -221,18 +221,17 @@ async def test_aclose_is_idempotent() -> None:
221221
await client.execute(Request(method=Method.GET, url=Url.parse("http://example.test/")))
222222

223223

224-
# ----------------------------------------------------- connect timeout (L36)
224+
# ----------------------------------------------------------- connect timeout
225225

226226

227227
class _ConnectTimeoutSession:
228228
"""Stub session whose ``request`` raises aiohttp's connect-phase timeout.
229229
230-
aiohttp only surfaces ``ConnectionTimeoutError`` for a connect-scoped
231-
timeout (``connect=`` / ``sock_connect=``); a plain ``total=`` timeout that
232-
happens to expire mid-connect raises a bare ``TimeoutError`` instead, which
233-
is indistinguishable from a read-phase timeout. We therefore drive the
234-
branch directly with the exception aiohttp actually raises for a connect
235-
timeout, keeping the test hermetic.
230+
aiohttp raises ``ConnectionTimeoutError`` for a connect-scoped timeout
231+
(``connect=`` / ``sock_connect=``) and ``SocketTimeoutError`` for a read
232+
timeout (``sock_read=``); the client configures both so the two phases stay
233+
distinguishable. We drive the connect branch directly with the exception
234+
aiohttp raises so the test stays hermetic (no real unreachable-host connect).
236235
"""
237236

238237
def request(self, **_kwargs: object) -> _ConnectTimeoutSession:
@@ -250,8 +249,43 @@ async def test_connect_timeout_maps_to_request_timeout() -> None:
250249
await client.execute(Request(method=Method.GET, url=Url.parse("http://example.test/")))
251250

252251

253-
async def test_total_timeout_maps_to_response_timeout(base_url: str) -> None:
254-
"""A bare total timeout (read phase) still maps to ServiceResponseTimeoutError."""
252+
class _CaptureTimeoutSession:
253+
"""Captures the ``ClientTimeout`` passed to ``request`` then fails the connect."""
254+
255+
def __init__(self) -> None:
256+
self.captured: aiohttp.ClientTimeout | None = None
257+
258+
def request(
259+
self, *, timeout: aiohttp.ClientTimeout, **_kwargs: object
260+
) -> _CaptureTimeoutSession:
261+
self.captured = timeout
262+
return self
263+
264+
def __await__(self) -> object:
265+
raise aiohttp.ConnectionTimeoutError("connect timed out")
266+
yield # pragma: no cover - makes this an awaitable generator
267+
268+
269+
async def test_timeout_configured_per_phase_so_connect_is_distinguishable() -> None:
270+
"""The client asks aiohttp for per-phase sock_connect/sock_read, not a total budget.
271+
272+
A total-only budget makes a connect-phase timeout raise a bare
273+
``TimeoutError`` indistinguishable from a read timeout; per-phase config
274+
makes connect raise ``ConnectionTimeoutError`` so it maps to a request error.
275+
"""
276+
session = _CaptureTimeoutSession()
277+
client = AiohttpHttpClient(timeout=5.0, session=session) # type: ignore[arg-type]
278+
with pytest.raises(ServiceRequestTimeoutError):
279+
await client.execute(Request(method=Method.GET, url=Url.parse("http://example.test/")))
280+
cfg = session.captured
281+
assert cfg is not None
282+
assert cfg.sock_connect == 5.0
283+
assert cfg.sock_read == 5.0
284+
assert cfg.total is None
285+
286+
287+
async def test_read_timeout_maps_to_response_timeout(base_url: str) -> None:
288+
"""A read-phase (sock_read) timeout maps to ServiceResponseTimeoutError."""
255289
async with AiohttpHttpClient(timeout=0.25) as client:
256290
with pytest.raises(ServiceResponseTimeoutError):
257291
await client.execute(Request(method=Method.GET, url=Url.parse(f"{base_url}/slow")))

packages/dexpace-sdk-http-requests/src/dexpace/sdk/http/requests/client.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
- ``requests.ReadTimeout`` -> `ServiceResponseTimeoutError`
1515
- ``requests.ConnectionError`` -> `ServiceRequestError`
1616
- ``requests.RequestException`` (catch-all) -> `ServiceRequestError`
17+
18+
Failures that surface later, while the response body is being streamed, are
19+
classified as response-side errors (the request was already sent): a
20+
``requests.Timeout`` -> `ServiceResponseTimeoutError` and any other
21+
``requests.RequestException`` -> `ServiceResponseError`.
1722
"""
1823

1924
from __future__ import annotations
@@ -184,16 +189,15 @@ def read(self, size: int = -1) -> bytes:
184189
if self._iter is None:
185190
self._iter = self._response.iter_content(chunk_size=_CHUNK_SIZE)
186191
if size < 0:
187-
for chunk in self._iter:
192+
while (chunk := self._next_chunk()) is not None:
188193
if chunk:
189194
self._buf.extend(chunk)
190195
out = bytes(self._buf)
191196
self._buf.clear()
192197
return out
193198
while len(self._buf) < size:
194-
try:
195-
chunk = next(self._iter)
196-
except StopIteration:
199+
chunk = self._next_chunk()
200+
if chunk is None:
197201
break
198202
if chunk:
199203
self._buf.extend(chunk)
@@ -204,6 +208,24 @@ def read(self, size: int = -1) -> bytes:
204208
del self._buf[:take]
205209
return out
206210

211+
def _next_chunk(self) -> bytes | None:
212+
"""Pull the next body chunk, mapping read-phase failures to SDK errors.
213+
214+
Returns the chunk, or ``None`` at end of stream. The request is already
215+
on the wire, so a read-phase failure is a response-side error: a
216+
``requests`` read timeout becomes ``ServiceResponseTimeoutError`` and
217+
any other transport failure mid-body becomes ``ServiceResponseError``.
218+
"""
219+
assert self._iter is not None
220+
try:
221+
return next(self._iter)
222+
except StopIteration:
223+
return None
224+
except requests.Timeout as err:
225+
raise ServiceResponseTimeoutError("Response body read timed out", error=err) from err
226+
except requests.RequestException as err:
227+
raise ServiceResponseError(f"Response body read failed: {err}", error=err) from err
228+
207229
def close(self) -> None:
208230
if self._closed:
209231
return

packages/dexpace-sdk-http-requests/tests/test_requests_client.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,63 @@ def test_unknown_status_closes_response() -> None:
236236
with client, pytest.raises(ServiceResponseError):
237237
client.execute(request)
238238
assert closed["yes"], "Response should be closed when status mapping fails"
239+
240+
241+
class _BodyFailureAdapter(requests.adapters.BaseAdapter):
242+
"""Returns a 200 whose body stream raises ``exc`` partway through the read."""
243+
244+
def __init__(self, exc: Exception) -> None:
245+
super().__init__()
246+
self._exc = exc
247+
248+
def send( # signature mirrors BaseAdapter.send
249+
self,
250+
request: requests.PreparedRequest,
251+
stream: bool = False,
252+
timeout: float | tuple[float | None, float | None] | None = None,
253+
verify: bool | str = True,
254+
cert: str | tuple[str, str] | None = None,
255+
proxies: dict[str, str] | None = None,
256+
) -> requests.Response:
257+
response = requests.Response()
258+
response.status_code = 200
259+
response.reason = "OK"
260+
response.url = request.url or ""
261+
response.raw = io.BytesIO(b"")
262+
exc = self._exc
263+
264+
def _raising_iter(chunk_size: int = 1, decode_unicode: bool = False) -> Iterator[bytes]:
265+
yield b"partial"
266+
raise exc
267+
268+
response.iter_content = _raising_iter # type: ignore[method-assign, assignment]
269+
return response
270+
271+
def close(self) -> None: # pragma: no cover - nothing to release
272+
pass
273+
274+
275+
def test_body_read_chunked_error_maps_to_service_response_error() -> None:
276+
"""A mid-body ``ChunkedEncodingError`` surfaces as ServiceResponseError."""
277+
session = requests.Session()
278+
session.mount("http://", _BodyFailureAdapter(requests.exceptions.ChunkedEncodingError("boom")))
279+
client = RequestsHttpClient(session=session)
280+
request = Request(method=Method.GET, url=Url.parse("http://example.test/"))
281+
with client:
282+
response = client.execute(request)
283+
assert response.body is not None
284+
with pytest.raises(ServiceResponseError):
285+
response.body.bytes()
286+
287+
288+
def test_body_read_timeout_maps_to_service_response_timeout_error() -> None:
289+
"""A mid-body ``ReadTimeout`` surfaces as ServiceResponseTimeoutError."""
290+
session = requests.Session()
291+
session.mount("http://", _BodyFailureAdapter(requests.exceptions.ReadTimeout("slow")))
292+
client = RequestsHttpClient(session=session)
293+
request = Request(method=Method.GET, url=Url.parse("http://example.test/"))
294+
with client:
295+
response = client.execute(request)
296+
assert response.body is not None
297+
with pytest.raises(ServiceResponseTimeoutError):
298+
response.body.bytes()

packages/dexpace-sdk-http-stdlib/src/dexpace/sdk/http/stdlib/asyncio_http_client.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,12 @@ async def execute(self, request: Request) -> AsyncResponse:
114114
raise
115115
except OSError as err:
116116
# A mid-exchange socket error after a successful connect is a
117-
# response-side failure, not a connect failure.
117+
# response-side failure, not a connect failure: the request may
118+
# already be on the wire, so it is deliberately classified as a
119+
# response error (not retry-safe) rather than the request error the
120+
# httpx / aiohttp / requests adapters use for their generic
121+
# transport bucket. The conservative choice avoids blind-retrying a
122+
# potentially-received non-idempotent request.
118123
raise ServiceResponseError(
119124
f"Exchange with {host}:{port} failed: {err}", error=err
120125
) from err

0 commit comments

Comments
 (0)