Skip to content

Commit f1ce688

Browse files
mishushakovclaude
andcommitted
feat(python-sdk): keep httpx.ReadTimeout as the API timeout exception
pyqwest raises the builtin TimeoutError for its transport timeouts, which would break callers catching httpx.TimeoutException; the adapter subclasses re-raise it as httpx.ReadTimeout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d22b89e commit f1ce688

4 files changed

Lines changed: 66 additions & 8 deletions

File tree

.changeset/python-pyqwest-rest-transport.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ proxy, instead of one pool per thread (sync) or per event loop (async), and
1616
single httpx client serves all threads and event loops.
1717
Connection-establishment failures are retried with backoff
1818
(`E2B_CONNECTION_RETRIES`, default 3), matching the connect-only retries of
19-
the previous transports.
19+
the previous transports. Timeouts keep raising `httpx.ReadTimeout` (an
20+
`httpx.TimeoutException`), as before.
2021

2122
`proxy` for API calls now takes a URL string (e.g.
2223
`proxy="http://user:pass@localhost:8030"`, scheme http, https, socks5, or

packages/python-sdk/e2b/api/client_async/__init__.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,28 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient:
2828

2929

3030
class AsyncApiPyqwestTransport(AsyncPyqwestTransport):
31-
"""Strip the ``Host`` header httpx adds to every request: hyper derives
31+
"""The SDK's tweaks on the stock pyqwest httpx adapter.
32+
33+
Strip the ``Host`` header httpx adds to every request: hyper derives
3234
HTTP/1 ``Host`` and HTTP/2 ``:authority`` from the URL itself, and
3335
forwarding an explicit ``host`` header on an HTTP/2 connection makes the
3436
E2B API edge reset the stream with PROTOCOL_ERROR. (Custom ``Host``
3537
overrides are therefore not honored, matching hyper's URL-derived
36-
behavior.)"""
38+
behavior.)
39+
40+
Re-raise pyqwest's timeouts (the builtin ``TimeoutError``, and
41+
``asyncio.TimeoutError`` from the adapter's deadline — distinct types
42+
until Python 3.11) as ``httpx.ReadTimeout``, preserving the
43+
``httpx.TimeoutException`` contract the httpx-native transport gave
44+
callers."""
3745

3846
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
3947
if "host" in request.headers:
4048
del request.headers["host"]
41-
return await super().handle_async_request(request)
49+
try:
50+
return await super().handle_async_request(request)
51+
except (TimeoutError, asyncio.TimeoutError) as e:
52+
raise httpx.ReadTimeout(str(e), request=request) from e
4253

4354

4455
class ConnectionRetryTransport(RetryTransport):

packages/python-sdk/e2b/api/client_sync/__init__.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,26 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient:
2626

2727

2828
class ApiPyqwestTransport(PyqwestTransport):
29-
"""Strip the ``Host`` header httpx adds to every request: hyper derives
29+
"""The SDK's tweaks on the stock pyqwest httpx adapter.
30+
31+
Strip the ``Host`` header httpx adds to every request: hyper derives
3032
HTTP/1 ``Host`` and HTTP/2 ``:authority`` from the URL itself, and
3133
forwarding an explicit ``host`` header on an HTTP/2 connection makes the
3234
E2B API edge reset the stream with PROTOCOL_ERROR. (Custom ``Host``
3335
overrides are therefore not honored, matching hyper's URL-derived
34-
behavior.)"""
36+
behavior.)
37+
38+
Re-raise pyqwest's timeouts (the builtin ``TimeoutError``) as
39+
``httpx.ReadTimeout``, preserving the ``httpx.TimeoutException`` contract
40+
the httpx-native transport gave callers."""
3541

3642
def handle_request(self, request: httpx.Request) -> httpx.Response:
3743
if "host" in request.headers:
3844
del request.headers["host"]
39-
return super().handle_request(request)
45+
try:
46+
return super().handle_request(request)
47+
except TimeoutError as e:
48+
raise httpx.ReadTimeout(str(e), request=request) from e
4049

4150

4251
class ConnectionRetryTransport(SyncRetryTransport):

packages/python-sdk/tests/test_api_client_transport.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import json
33
import ssl
44
import threading
5+
import time
56
from concurrent.futures import ThreadPoolExecutor
67
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
78
from typing import cast
@@ -364,9 +365,12 @@ async def test_async_envd_transport_uses_separate_stack(test_api_key):
364365

365366

366367
class _EchoHandler(BaseHTTPRequestHandler):
367-
"""Answers every GET with a JSON echo of the request headers."""
368+
"""Answers every GET with a JSON echo of the request headers; a path
369+
starting with ``/slow`` sleeps 5 seconds first."""
368370

369371
def do_GET(self):
372+
if self.path.startswith("/slow"):
373+
time.sleep(5)
370374
headers = {k.lower(): v for k, v in self.headers.items()}
371375
body = json.dumps({"path": self.path, "headers": headers}).encode()
372376
self.send_response(200)
@@ -469,3 +473,36 @@ async def test_async_api_client_round_trips_through_pyqwest(test_api_key, echo_s
469473
finally:
470474
await httpx_client.aclose()
471475
reset_async_api_transports()
476+
477+
478+
def test_sync_api_client_timeout_raises_httpx_read_timeout(test_api_key, echo_server):
479+
# pyqwest raises the builtin TimeoutError; the transport re-raises it as
480+
# httpx.ReadTimeout to keep the httpx.TimeoutException contract.
481+
reset_sync_api_transports()
482+
config = ConnectionConfig(api_key=test_api_key, api_url=echo_server)
483+
api_client = get_sync_api_client(config)
484+
httpx_client = api_client.get_httpx_client()
485+
486+
try:
487+
with pytest.raises(httpx.ReadTimeout):
488+
httpx_client.request("GET", "/slow", timeout=0.2)
489+
finally:
490+
httpx_client.close()
491+
reset_sync_api_transports()
492+
493+
494+
@pytest.mark.asyncio
495+
async def test_async_api_client_timeout_raises_httpx_read_timeout(
496+
test_api_key, echo_server
497+
):
498+
reset_async_api_transports()
499+
config = ConnectionConfig(api_key=test_api_key, api_url=echo_server)
500+
api_client = get_async_api_client(config)
501+
httpx_client = api_client.get_async_httpx_client()
502+
503+
try:
504+
with pytest.raises(httpx.ReadTimeout):
505+
await httpx_client.request("GET", "/slow", timeout=0.2)
506+
finally:
507+
await httpx_client.aclose()
508+
reset_async_api_transports()

0 commit comments

Comments
 (0)