33import httpx
44import threading
55
6- from httpx ._types import ProxyTypes
76from pyqwest import SyncHTTPTransport , SyncRequest , SyncResponse
87from pyqwest .httpx import PyqwestTransport
98from pyqwest .middleware .retry import SyncRetryTransport
109
1110from e2b .api import (
1211 ApiClient ,
1312 connection_retries ,
14- limits ,
13+ make_logging_event_hooks ,
1514 pool_idle_timeout ,
1615 pool_max_idle_per_host ,
1716 proxy_to_url ,
1817)
19- from e2b .connection_config import ConnectionConfig
20-
21- TransportKey = Tuple [bool , Optional [ProxyTypes ]]
18+ from e2b .connection_config import READ_TIMEOUT , ConnectionConfig
2219
2320
2421def get_api_client (config : ConnectionConfig , ** kwargs ) -> ApiClient :
2522 return ApiClient (config , transport = get_transport (config ), ** kwargs )
2623
2724
25+ class _SyncOnlyStream (httpx .SyncByteStream ):
26+ """Present a dual sync/async httpx request stream as sync-only.
27+
28+ httpx's ``MultipartStream`` (``files=`` uploads) implements both
29+ ``SyncByteStream`` and ``AsyncByteStream``; the stock adapter's content
30+ conversion matches ``AsyncByteStream`` first and raises
31+ ``TypeError("unreachable")`` from inside the body iterator, which
32+ surfaces as a ``WriteError`` mid-request."""
33+
34+ def __init__ (self , stream : httpx .SyncByteStream ):
35+ self ._stream = stream
36+
37+ def __iter__ (self ):
38+ return iter (self ._stream )
39+
40+ def close (self ) -> None :
41+ self ._stream .close ()
42+
43+
2844class ApiPyqwestTransport (PyqwestTransport ):
2945 """The SDK's tweaks on the stock pyqwest httpx adapter.
3046
@@ -42,6 +58,13 @@ class ApiPyqwestTransport(PyqwestTransport):
4258 def handle_request (self , request : httpx .Request ) -> httpx .Response :
4359 if "host" in request .headers :
4460 del request .headers ["host" ]
61+ stream = request .stream
62+ if (
63+ isinstance (stream , httpx .SyncByteStream )
64+ and isinstance (stream , httpx .AsyncByteStream )
65+ and not isinstance (stream , httpx .ByteStream )
66+ ):
67+ request .stream = _SyncOnlyStream (stream )
4568 try :
4669 return super ().handle_request (request )
4770 except TimeoutError as e :
@@ -64,27 +87,33 @@ def should_retry_response(
6487 return isinstance (response , ConnectionError )
6588
6689
67- def retrying_http_transport (proxy_url : Optional [str ]) -> ConnectionRetryTransport :
90+ def retrying_http_transport (
91+ proxy_url : Optional [str ], read_timeout : Optional [float ] = None
92+ ) -> ConnectionRetryTransport :
6893 """A fresh pyqwest transport (= its own connection pool) with the SDK's
6994 shared tuning — system CA certs (without which TLS through an
7095 intercepting proxy fails), the httpx-equivalent pool limits, and
71- connect-only retries. The REST API and envd RPC stacks each cache their
72- own instances (pool unification is a follow-up)."""
96+ connect-only retries. The REST API, envd RPC, and envd HTTP API stacks
97+ each cache their own instances (pool unification is a follow-up).
98+
99+ ``read_timeout`` bounds every read on the transport's connections; see
100+ :func:`get_envd_transport` for when that is (and isn't) appropriate."""
73101 return ConnectionRetryTransport (
74102 SyncHTTPTransport (
75103 tls_include_system_certs = True ,
76104 proxy = proxy_url ,
77105 pool_idle_timeout = pool_idle_timeout ,
78106 pool_max_idle_per_host = pool_max_idle_per_host ,
107+ read_timeout = read_timeout ,
79108 ),
80109 max_retries = connection_retries ,
81110 )
82111
83112
84113_transport_lock = threading .Lock ()
85114# One transport (= one connection pool) per proxy; None is the direct pool.
86- # pyqwest transports are thread-safe, so unlike the httpx envd transports
87- # below , the cache is process-global rather than per-thread.
115+ # pyqwest transports are thread-safe, so unlike the httpx transports they
116+ # replaced , the caches are process-global rather than per-thread.
88117_transports : Dict [Optional [str ], "ApiPyqwestTransport" ] = {}
89118
90119
@@ -101,31 +130,53 @@ def get_transport(config: ConnectionConfig) -> "ApiPyqwestTransport":
101130 return transport
102131
103132
104- class EnvdTransportWithLogger (httpx .HTTPTransport ):
105- _thread_local = threading .local ()
106-
107- @property
108- def pool (self ):
109- return self ._pool
133+ # One transport per (proxy, streaming) pair, separate from the REST API
134+ # pools — envd traffic goes to per-sandbox hosts.
135+ _envd_transports : Dict [Tuple [Optional [str ], bool ], "ApiPyqwestTransport" ] = {}
110136
111137
112138def get_envd_transport (
113- config : ConnectionConfig , http2 : bool = True
114- ) -> EnvdTransportWithLogger :
115- instances : Dict [TransportKey , EnvdTransportWithLogger ] = getattr (
116- EnvdTransportWithLogger ._thread_local , "instances" , {}
117- )
118- key : TransportKey = (http2 , config .proxy )
119- cached = instances .get (key )
120- if cached is not None :
121- return cached
122-
123- transport = EnvdTransportWithLogger (
124- limits = limits ,
125- proxy = config .proxy ,
126- http2 = http2 ,
127- retries = connection_retries ,
139+ config : ConnectionConfig , * , for_streaming : bool = False
140+ ) -> "ApiPyqwestTransport" :
141+ """The shared pyqwest-backed httpx transports for the envd HTTP API
142+ (file transfers, health checks).
143+
144+ The streaming transport carries ``read_timeout``, the idle bound on
145+ every read: it resets after each successful read, so it caps how long a
146+ streamed download may stall without limiting total transfer time. It is
147+ fixed per transport — the adapter's per-request timeouts are
148+ whole-request deadlines, and the sync adapter does not bound body reads
149+ at all. Only streamed downloads use it: reqwest's read timer keeps
150+ running while a request body is sent and while waiting for the response
151+ head, so on the regular transport it would cut off uploads and slow
152+ unary responses longer than the idle bound (those stay bounded by their
153+ whole-request deadlines instead).
154+ """
155+ proxy_url = proxy_to_url (config .proxy )
156+ key = (proxy_url , for_streaming )
157+ with _transport_lock :
158+ transport = _envd_transports .get (key )
159+ if transport is None :
160+ transport = ApiPyqwestTransport (
161+ retrying_http_transport (
162+ proxy_url ,
163+ read_timeout = READ_TIMEOUT if for_streaming else None ,
164+ )
165+ )
166+ _envd_transports [key ] = transport
167+ return transport
168+
169+
170+ def get_envd_api (
171+ config : ConnectionConfig , base_url : str , * , for_streaming : bool = False
172+ ) -> httpx .Client :
173+ """An httpx client for a sandbox's envd HTTP API (file transfers, health
174+ checks) on the shared pyqwest transports. The client itself is a cheap
175+ stateless wrapper — one per consumer is fine — while the pooled transport
176+ underneath is shared and thread-safe."""
177+ return httpx .Client (
178+ base_url = base_url ,
179+ transport = get_envd_transport (config , for_streaming = for_streaming ),
180+ headers = config .sandbox_headers ,
181+ event_hooks = make_logging_event_hooks (config .logger ),
128182 )
129- instances [key ] = transport
130- EnvdTransportWithLogger ._thread_local .instances = instances
131- return transport
0 commit comments