Skip to content

Commit 7bb223c

Browse files
mishushakovclaude
andcommitted
feat(python-sdk): reduce httpx.Proxy proxies to URLs, keep stream_idle_timeout as no-op
proxy_to_url now accepts httpx.Proxy when it reduces to a plain proxy URL (auth folded back into the userinfo), matching the envd RPC proxy narrowing in #1558; extras pyqwest can't express (custom headers, ssl_context) still raise InvalidArgumentException. Applies to the REST API, volume content, and template upload transports alike. Volume.read_file keeps stream_idle_timeout in its signature for backwards compatibility but ignores it — the transport-wide idle read timeout bounds stalled streams. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7459367 commit 7bb223c

6 files changed

Lines changed: 71 additions & 12 deletions

File tree

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ event hooks, per-request timeouts, and headers behave as before.
1313
For streamed volume reads (`Volume.read_file(format="stream")`), a stalled
1414
stream is now bounded by a transport-wide idle read timeout that resets on
1515
every chunk (still surfaced as `httpx.ReadTimeout`); the per-call
16-
`stream_idle_timeout` parameter is removed. Passing `request_timeout` to a
17-
streamed read (and to template uploads) now bounds the whole transfer rather
18-
than individual socket operations.
16+
`stream_idle_timeout` parameter is deprecated and ignored. Passing
17+
`request_timeout` to a streamed read (and to template uploads) now bounds
18+
the whole transfer rather than individual socket operations.
1919

2020
Because pyqwest transports are thread-safe and loop-independent (I/O runs on
2121
a Rust runtime), the API connection pool is now shared process-wide per
@@ -26,10 +26,13 @@ Connection-establishment failures are retried with backoff
2626
(`E2B_CONNECTION_RETRIES`, default 3), matching the connect-only retries of
2727
the previous transports.
2828

29-
`proxy` for API calls must now be a URL string (e.g.
29+
`proxy` for API calls now takes a URL string (e.g.
3030
`proxy="http://user:pass@localhost:8030"`, scheme http, https, socks5, or
31-
socks5h); the richer `httpx.Proxy` objects are rejected with
32-
`InvalidArgumentException` rather than partially honored.
31+
socks5h). `httpx.URL` and `httpx.Proxy` keep working when they reduce to
32+
such a URL (`httpx.Proxy` auth is folded back into the URL userinfo);
33+
`httpx.Proxy` extras pyqwest can't express — custom headers, an
34+
`ssl_context` — raise `InvalidArgumentException` rather than being silently
35+
dropped.
3336

3437
envd traffic (sandbox commands, filesystem, PTY, file transfers) is not
3538
affected and stays on its existing stack.

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,15 +81,31 @@ async def on_response(response: Response) -> None:
8181
def proxy_to_url(proxy: Optional[ProxyTypes]) -> Optional[str]:
8282
"""Narrow the ``proxy`` connection option to the proxy URL string pyqwest
8383
transports take (scheme http, https, socks5, or socks5h, credentials in
84-
the URL userinfo). The richer ``httpx.Proxy`` objects the httpx transports
85-
accepted (per-proxy auth, headers, TLS context) are rejected rather than
86-
partially honored."""
84+
the URL userinfo). ``httpx.URL`` and ``httpx.Proxy`` — which the httpx
85+
transports accepted — are converted when they reduce to such a URL;
86+
``httpx.Proxy`` extras that don't (custom headers, an ssl_context) are
87+
rejected rather than silently dropped."""
8788
if proxy is None:
8889
return None
8990
if isinstance(proxy, str):
9091
return proxy
9192
if isinstance(proxy, httpx.URL):
9293
return str(proxy)
94+
if isinstance(proxy, httpx.Proxy):
95+
if proxy.headers:
96+
raise InvalidArgumentException(
97+
"E2B API calls don't support httpx.Proxy custom headers; "
98+
"pass credentials in the proxy URL instead, "
99+
'e.g. proxy="http://user:pass@localhost:8030"'
100+
)
101+
if proxy.ssl_context is not None:
102+
raise InvalidArgumentException(
103+
"E2B API calls don't support httpx.Proxy ssl_context"
104+
)
105+
url = proxy.url
106+
if proxy.auth is not None:
107+
url = url.copy_with(username=proxy.auth[0], password=proxy.auth[1])
108+
return str(url)
93109
raise InvalidArgumentException(
94110
"E2B API calls support only URL-string proxies, "
95111
'e.g. proxy="http://user:pass@localhost:8030"'

packages/python-sdk/e2b/volume/volume_async.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,13 +448,15 @@ async def read_file(
448448
self,
449449
path: str,
450450
format: Literal["stream"],
451+
stream_idle_timeout: Optional[float] = None,
451452
**opts: Unpack[VolumeApiParams],
452453
) -> AsyncIterator[bytes]: ...
453454

454455
async def read_file(
455456
self,
456457
path: str,
457458
format: Literal["text", "bytes", "stream"] = "text",
459+
stream_idle_timeout: Optional[float] = None,
458460
**opts: Unpack[VolumeApiParams],
459461
) -> Union[str, bytes, AsyncIterator[bytes]]:
460462
"""
@@ -464,6 +466,9 @@ async def read_file(
464466
465467
:param path: Path to the file
466468
:param format: Format of the file content—`text` by default
469+
:param stream_idle_timeout: Deprecated and ignored. A stalled streamed
470+
read is bounded by a transport-wide idle read timeout instead,
471+
which resets on every chunk.
467472
:param opts: Connection options
468473
469474
:return: File content as string, bytes, or async iterator of bytes

packages/python-sdk/e2b/volume/volume_sync.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,13 +445,15 @@ def read_file(
445445
self,
446446
path: str,
447447
format: Literal["stream"],
448+
stream_idle_timeout: Optional[float] = None,
448449
**opts: Unpack[VolumeApiParams],
449450
) -> Iterator[bytes]: ...
450451

451452
def read_file(
452453
self,
453454
path: str,
454455
format: Literal["text", "bytes", "stream"] = "text",
456+
stream_idle_timeout: Optional[float] = None,
455457
**opts: Unpack[VolumeApiParams],
456458
) -> Union[str, bytes, Iterator[bytes]]:
457459
"""
@@ -461,6 +463,9 @@ def read_file(
461463
462464
:param path: Path to the file
463465
:param format: Format of the file content—`text` by default
466+
:param stream_idle_timeout: Deprecated and ignored. A stalled streamed
467+
read is bounded by a transport-wide idle read timeout instead,
468+
which resets on every chunk.
464469
:param opts: Connection options
465470
466471
:return: File content as string, bytes, or iterator of bytes

packages/python-sdk/tests/test_api_client_transport.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import json
3+
import ssl
34
import threading
45
from concurrent.futures import ThreadPoolExecutor
56
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -54,8 +55,31 @@ def test_proxy_to_url_narrows_to_url_strings():
5455
assert proxy_to_url(None) is None
5556
assert proxy_to_url("http://127.0.0.1:9999") == "http://127.0.0.1:9999"
5657
assert proxy_to_url(httpx.URL("http://127.0.0.1:9999")) == "http://127.0.0.1:9999"
58+
59+
60+
def test_proxy_to_url_reduces_httpx_proxy():
61+
# httpx.Proxy converts when it reduces to a plain proxy URL, with
62+
# credentials (which httpx.Proxy splits off the URL) folded back into
63+
# the userinfo.
64+
assert proxy_to_url(httpx.Proxy("http://127.0.0.1:9999")) == "http://127.0.0.1:9999"
65+
assert (
66+
proxy_to_url(httpx.Proxy("http://user:pass@127.0.0.1:9999"))
67+
== "http://user:pass@127.0.0.1:9999"
68+
)
69+
assert (
70+
proxy_to_url(httpx.Proxy("http://127.0.0.1:9999", auth=("user", "pass")))
71+
== "http://user:pass@127.0.0.1:9999"
72+
)
73+
74+
# Extras pyqwest can't express are rejected rather than silently dropped.
75+
with pytest.raises(InvalidArgumentException):
76+
proxy_to_url(httpx.Proxy("http://127.0.0.1:9999", headers={"X-Auth": "t"}))
5777
with pytest.raises(InvalidArgumentException):
58-
proxy_to_url(httpx.Proxy("http://127.0.0.1:9999"))
78+
proxy_to_url(
79+
httpx.Proxy(
80+
"https://127.0.0.1:9999", ssl_context=ssl.create_default_context()
81+
)
82+
)
5983

6084

6185
def test_connection_retry_policy_retries_only_connection_errors():

packages/python-sdk/tests/test_volume_client.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,10 +180,14 @@ def short_read_timeout(monkeypatch):
180180
def test_sync_stream_survives_transfers_longer_than_read_timeout(short_read_timeout):
181181
# The transport read timeout is an idle bound that resets on every chunk:
182182
# a healthy stream whose total duration exceeds it must complete.
183+
# `stream_idle_timeout` is deprecated and ignored — a value shorter than
184+
# every chunk gap must not abort the stream.
183185
api_url = _start_volume_file_server([0.15] * 4)
184186
volume = Volume(volume_id="v1", name="test", token="vol-token")
185187

186-
stream = volume.read_file("file.bin", format="stream", api_url=api_url)
188+
stream = volume.read_file(
189+
"file.bin", format="stream", stream_idle_timeout=0.01, api_url=api_url
190+
)
187191
assert b"".join(stream) == CHUNK * 4
188192

189193

@@ -206,7 +210,9 @@ def test_async_stream_survives_transfers_longer_than_read_timeout(short_read_tim
206210
volume = AsyncVolume(volume_id="v1", name="test", token="vol-token")
207211

208212
async def run():
209-
stream = await volume.read_file("file.bin", format="stream", api_url=api_url)
213+
stream = await volume.read_file(
214+
"file.bin", format="stream", stream_idle_timeout=0.01, api_url=api_url
215+
)
210216
return b"".join([chunk async for chunk in stream])
211217

212218
assert asyncio.run(run()) == CHUNK * 4

0 commit comments

Comments
 (0)