11import asyncio
22import threading
3- import weakref
43from typing import Dict , Optional , Tuple , Union
54
65import httpx
76
8- from httpx ._types import ProxyTypes
97from pyqwest import HTTPTransport , Request , Response
108from pyqwest .httpx import AsyncPyqwestTransport
119from pyqwest .middleware .retry import RetryTransport
1412 AsyncApiClient ,
1513 ProxyConfig ,
1614 connection_retries ,
17- limits ,
15+ make_async_logging_event_hooks ,
1816 pool_idle_timeout ,
1917 pool_max_idle_per_host ,
2018 proxy_to_config ,
2119)
22- from e2b .connection_config import ConnectionConfig
23-
24- TransportKey = Tuple [bool , Optional [ProxyTypes ]]
20+ from e2b .connection_config import READ_TIMEOUT , ConnectionConfig
2521
2622
2723def get_api_client (config : ConnectionConfig , ** kwargs ) -> AsyncApiClient :
@@ -70,13 +66,17 @@ def should_retry_response(
7066
7167
7268def retrying_http_transport (
73- proxy : Optional [ProxyConfig ],
69+ proxy : Optional [ProxyConfig ], read_timeout : Optional [ float ] = None
7470) -> ConnectionRetryTransport :
7571 """A fresh pyqwest transport (= its own connection pool) with the SDK's
7672 shared tuning — system CA certs (without which TLS through an
7773 intercepting proxy fails), the httpx-equivalent pool limits, and
78- connect-only retries. The REST API and envd RPC stacks each cache their
79- own instances (pool unification is a follow-up).
74+ connect-only retries. The REST API, envd RPC, and envd HTTP API stacks
75+ each cache their own instances (pool unification is a follow-up).
76+
77+ ``read_timeout`` bounds every read on the transport's connections; see
78+ :func:`get_envd_transport` for when that is (and isn't) appropriate.
79+
8080 Requests are logged by pyqwest itself on the ``pyqwest.access`` and
8181 ``pyqwest`` loggers at ``DEBUG`` (off unless enabled) — the transport-level
8282 diagnostics httpcore used to provide. The SDK's own ``logger`` option is
@@ -87,16 +87,17 @@ def retrying_http_transport(
8787 proxy = proxy .to_pyqwest () if proxy is not None else None ,
8888 pool_idle_timeout = pool_idle_timeout ,
8989 pool_max_idle_per_host = pool_max_idle_per_host ,
90+ read_timeout = read_timeout ,
9091 ),
9192 max_retries = connection_retries ,
9293 )
9394
9495
9596_transport_lock = threading .Lock ()
9697# One transport (= one connection pool) per proxy; None is the direct pool.
97- # pyqwest's I/O runs on its own Rust runtime, so unlike the httpx envd
98- # transports below , the transport is not bound to an event loop and the
99- # cache is process-global rather than per-loop.
98+ # pyqwest's I/O runs on its own Rust runtime, so unlike the httpx transports
99+ # they replaced , the transports are not bound to an event loop and the
100+ # caches are process-global rather than per-loop.
100101_transports : Dict [Optional [ProxyConfig ], "AsyncApiPyqwestTransport" ] = {}
101102
102103
@@ -113,38 +114,54 @@ def get_transport(config: ConnectionConfig) -> "AsyncApiPyqwestTransport":
113114 return transport
114115
115116
116- class AsyncEnvdTransportWithLogger (httpx .AsyncHTTPTransport ):
117- # Keyed weakly by the event loop object itself, not id(loop) — CPython
118- # reuses object ids, so a new loop could otherwise inherit a transport
119- # bound to a previous, closed loop.
120- _instances : weakref .WeakKeyDictionary [
121- asyncio .AbstractEventLoop ,
122- Dict [TransportKey , "AsyncEnvdTransportWithLogger" ],
123- ] = weakref .WeakKeyDictionary ()
124-
125- @property
126- def pool (self ):
127- return self ._pool
117+ # One transport per (proxy, streaming) pair, separate from the REST API
118+ # pools — envd traffic goes to per-sandbox hosts.
119+ _envd_transports : Dict [
120+ Tuple [Optional [ProxyConfig ], bool ], "AsyncApiPyqwestTransport"
121+ ] = {}
128122
129123
130124def get_envd_transport (
131- config : ConnectionConfig , http2 : bool = True
132- ) -> AsyncEnvdTransportWithLogger :
133- loop = asyncio .get_running_loop ()
134- loop_instances = AsyncEnvdTransportWithLogger ._instances .get (loop )
135- if loop_instances is None :
136- loop_instances = {}
137- AsyncEnvdTransportWithLogger ._instances [loop ] = loop_instances
138-
139- key : TransportKey = (http2 , config .proxy )
140- transport = loop_instances .get (key )
141- if transport is None :
142- transport = AsyncEnvdTransportWithLogger (
143- limits = limits ,
144- proxy = config .proxy ,
145- http2 = http2 ,
146- retries = connection_retries ,
147- )
148- loop_instances [key ] = transport
149-
150- return transport
125+ config : ConnectionConfig , * , for_streaming : bool = False
126+ ) -> "AsyncApiPyqwestTransport" :
127+ """The shared pyqwest-backed httpx transports for the envd HTTP API
128+ (file transfers, health checks).
129+
130+ The streaming transport carries ``read_timeout``, the idle bound on
131+ every read: it resets after each successful read, so it caps how long a
132+ streamed download may stall without limiting total transfer time. It is
133+ fixed per transport — the adapter's per-request timeouts are
134+ whole-request deadlines. Only streamed downloads use it: reqwest's read
135+ timer keeps running while a request body is sent and while waiting for
136+ the response head, so on the regular transport it would cut off uploads
137+ and slow unary responses longer than the idle bound (those stay bounded
138+ by their whole-request deadlines instead).
139+ """
140+ proxy = proxy_to_config (config .proxy )
141+ key = (proxy , for_streaming )
142+ with _transport_lock :
143+ transport = _envd_transports .get (key )
144+ if transport is None :
145+ transport = AsyncApiPyqwestTransport (
146+ retrying_http_transport (
147+ proxy ,
148+ read_timeout = READ_TIMEOUT if for_streaming else None ,
149+ )
150+ )
151+ _envd_transports [key ] = transport
152+ return transport
153+
154+
155+ def get_envd_api (
156+ config : ConnectionConfig , base_url : str , * , for_streaming : bool = False
157+ ) -> httpx .AsyncClient :
158+ """An httpx client for a sandbox's envd HTTP API (file transfers, health
159+ checks) on the shared pyqwest transports. The client itself is a cheap
160+ stateless wrapper — one per consumer is fine — while the pooled transport
161+ underneath is shared and loop-independent."""
162+ return httpx .AsyncClient (
163+ base_url = base_url ,
164+ transport = get_envd_transport (config , for_streaming = for_streaming ),
165+ headers = config .sandbox_headers ,
166+ event_hooks = make_async_logging_event_hooks (config .logger ),
167+ )
0 commit comments