Skip to content

Commit 3d2e3bc

Browse files
mishushakovclaude
andcommitted
refactor(python-sdk): drop per-thread/per-loop client caching from ApiClient
With the pyqwest transports thread-safe and loop-independent, the transport_factory/async_transport_factory plumbing, the thread-local httpx.Client cache, and the per-loop WeakKeyDictionary of AsyncClients are dead weight: a single lazily-created httpx client (the generated base behavior, same shape the volume client already uses) serves all threads and event loops over the shared per-proxy pool. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6d0ee19 commit 3d2e3bc

5 files changed

Lines changed: 75 additions & 138 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ event hooks, per-request timeouts, and headers behave as before.
1111

1212
Because pyqwest transports are thread-safe and loop-independent (I/O runs on
1313
a Rust runtime), the API connection pool is now shared process-wide per
14-
proxy, instead of one pool per thread (sync) or per event loop (async).
14+
proxy, instead of one pool per thread (sync) or per event loop (async), and
15+
`ApiClient` no longer maintains per-thread/per-loop httpx client caches — a
16+
single httpx client serves all threads and event loops.
1517
Connection-establishment failures are retried with backoff
1618
(`E2B_CONNECTION_RETRIES`, default 3), matching the connect-only retries of
1719
the previous transports.

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

Lines changed: 9 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
1-
import asyncio
21
import json
32
import logging
43
import os
54
import re
6-
import threading
7-
import weakref
85
from dataclasses import dataclass
96
from types import TracebackType
10-
from typing import Callable, Optional, Protocol, Union
7+
from typing import Optional, Protocol, Union
118

129
import httpx
1310
from httpx import AsyncBaseTransport, BaseTransport, Limits, Timeout
@@ -182,31 +179,20 @@ def validate_api_key(api_key: str) -> None:
182179
class ApiClient(AuthenticatedClient):
183180
"""
184181
The client for interacting with the E2B API.
182+
183+
A single lazily-created httpx client (see the generated
184+
``AuthenticatedClient``) serves all threads and event loops: the pyqwest
185+
transports it delegates to are thread-safe and loop-independent, and
186+
``httpx.Client`` is documented thread-safe.
185187
"""
186188

187189
def __init__(
188190
self,
189191
config: ConnectionConfig,
190192
transport: Optional[Union[BaseTransport, AsyncBaseTransport]] = None,
191-
transport_factory: Optional[Callable[[], BaseTransport]] = None,
192-
async_transport_factory: Optional[Callable[[], AsyncBaseTransport]] = None,
193193
*args,
194194
**kwargs,
195195
):
196-
if transport is not None and (
197-
transport_factory is not None or async_transport_factory is not None
198-
):
199-
raise ValueError("Use either transport or transport_factory, not both")
200-
201-
self._transport_factory = transport_factory
202-
self._async_transport_factory = async_transport_factory
203-
self._thread_local = threading.local()
204-
# Keyed weakly by the event loop object itself, not id(loop) —
205-
# CPython reuses object ids, so a new loop could otherwise inherit
206-
# a client bound to a previous, closed loop.
207-
self._async_clients: weakref.WeakKeyDictionary[
208-
asyncio.AbstractEventLoop, httpx.AsyncClient
209-
] = weakref.WeakKeyDictionary()
210196
self._proxy = config.proxy
211197

212198
if config.api_key is None:
@@ -251,12 +237,10 @@ def __init__(
251237
"event_hooks": self._logging_event_hooks(),
252238
}
253239
if transport is not None:
240+
# The proxy lives in the transport; passing `proxy` here too
241+
# would mount a fresh, never-closed proxy transport per client.
254242
httpx_args["transport"] = transport
255-
if (
256-
transport is None
257-
and transport_factory is None
258-
and async_transport_factory is None
259-
):
243+
else:
260244
httpx_args["proxy"] = config.proxy
261245

262246
# config.request_timeout is None when the timeout is explicitly
@@ -277,53 +261,6 @@ def __init__(
277261
def _logging_event_hooks(self) -> dict:
278262
return make_logging_event_hooks(self._logger)
279263

280-
def _headers_with_auth(self) -> dict:
281-
return {
282-
**self._headers,
283-
self.auth_header_name: (
284-
f"{self.prefix} {self.token}" if self.prefix else self.token
285-
),
286-
}
287-
288-
def get_httpx_client(self) -> httpx.Client:
289-
if self._client is not None or self._transport_factory is None:
290-
return super().get_httpx_client()
291-
292-
client = getattr(self._thread_local, "client", None)
293-
if client is None:
294-
client = httpx.Client(
295-
base_url=self._base_url,
296-
cookies=self._cookies,
297-
headers=self._headers_with_auth(),
298-
timeout=self._timeout,
299-
verify=self._verify_ssl,
300-
follow_redirects=self._follow_redirects,
301-
event_hooks=self._httpx_args.get("event_hooks"),
302-
transport=self._transport_factory(),
303-
)
304-
self._thread_local.client = client
305-
return client
306-
307-
def get_async_httpx_client(self) -> httpx.AsyncClient:
308-
if self._async_client is not None or self._async_transport_factory is None:
309-
return super().get_async_httpx_client()
310-
311-
loop = asyncio.get_running_loop()
312-
client = self._async_clients.get(loop)
313-
if client is None:
314-
client = httpx.AsyncClient(
315-
base_url=self._base_url,
316-
cookies=self._cookies,
317-
headers=self._headers_with_auth(),
318-
timeout=self._timeout,
319-
verify=self._verify_ssl,
320-
follow_redirects=self._follow_redirects,
321-
event_hooks=self._httpx_args.get("event_hooks"),
322-
transport=self._async_transport_factory(),
323-
)
324-
self._async_clients[loop] = client
325-
return client
326-
327264

328265
# We need to override the logging hooks for the async usage
329266
class AsyncApiClient(ApiClient):

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,7 @@
2424

2525

2626
def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient:
27-
return AsyncApiClient(
28-
config,
29-
async_transport_factory=lambda: get_transport(config),
30-
**kwargs,
31-
)
27+
return AsyncApiClient(config, transport=get_transport(config), **kwargs)
3228

3329

3430
class AsyncApiPyqwestTransport(AsyncPyqwestTransport):

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,7 @@
2222

2323

2424
def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient:
25-
return ApiClient(
26-
config,
27-
transport_factory=lambda: get_transport(config),
28-
**kwargs,
29-
)
25+
return ApiClient(config, transport=get_transport(config), **kwargs)
3026

3127

3228
class ApiPyqwestTransport(PyqwestTransport):

packages/python-sdk/tests/test_api_client_transport.py

Lines changed: 61 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -233,38 +233,20 @@ def test_sync_api_transport_cache_is_shared_across_threads(test_api_key):
233233
reset_sync_api_transports()
234234

235235

236-
def test_sync_api_client_reused_within_thread_and_shares_transport_across_threads(
237-
test_api_key,
238-
):
236+
def test_sync_api_client_is_shared_across_threads(test_api_key):
237+
# httpx.Client is thread-safe and the pyqwest transport underneath is
238+
# too, so a single client (and its pool) serves all threads — the
239+
# per-thread client caching this replaced is gone.
239240
reset_sync_api_transports()
240241
config = ConnectionConfig(api_key=test_api_key)
241242
api_client = get_sync_api_client(config)
242243

243-
def get_worker_client_ids():
244-
httpx_client = api_client.get_httpx_client()
245-
try:
246-
return (
247-
id(httpx_client),
248-
id(api_client.get_httpx_client()),
249-
id(httpx_client._transport),
250-
)
251-
finally:
252-
httpx_client.close()
253-
254244
try:
255245
main_client = api_client.get_httpx_client()
256-
(
257-
worker_client_id,
258-
worker_cached_client_id,
259-
worker_transport_id,
260-
) = run_in_worker_thread(get_worker_client_ids)
246+
worker_client = run_in_worker_thread(api_client.get_httpx_client)
261247

262248
assert api_client.get_httpx_client() is main_client
263-
# The httpx client wrapper stays per-thread, but every thread's
264-
# client delegates to the same shared pyqwest transport.
265-
assert worker_client_id == worker_cached_client_id
266-
assert worker_client_id != id(main_client)
267-
assert worker_transport_id == id(main_client._transport)
249+
assert worker_client is main_client
268250
finally:
269251
main_client.close()
270252
reset_sync_api_transports()
@@ -345,42 +327,27 @@ async def test_async_api_client_applies_request_timeout(test_api_key):
345327

346328

347329
@pytest.mark.asyncio
348-
async def test_async_api_client_cache_reuses_within_loop_and_shares_transport(
349-
test_api_key,
350-
):
330+
async def test_async_api_client_is_shared_across_loops(test_api_key):
331+
# pyqwest's I/O runs on its own Rust runtime, so neither the transport
332+
# nor the httpx client wrapper is bound to an event loop — a single
333+
# client serves all loops (the per-loop client caching this replaced is
334+
# gone).
351335
reset_async_api_transports()
352336
config = ConnectionConfig(api_key=test_api_key)
353337
api_client = get_async_api_client(config)
354338

355-
async def get_client_ids():
356-
httpx_client = api_client.get_async_httpx_client()
357-
try:
358-
return (
359-
id(httpx_client),
360-
id(api_client.get_async_httpx_client()),
361-
id(httpx_client._transport),
362-
)
363-
finally:
364-
await httpx_client.aclose()
339+
async def get_client():
340+
return api_client.get_async_httpx_client()
365341

366342
try:
367343
main_client = api_client.get_async_httpx_client()
368-
(
369-
worker_client_id,
370-
worker_cached_client_id,
371-
worker_transport_id,
372-
) = await asyncio.get_running_loop().run_in_executor(
344+
other_loop_client = await asyncio.get_running_loop().run_in_executor(
373345
None,
374-
lambda: asyncio.run(get_client_ids()),
346+
lambda: asyncio.run(get_client()),
375347
)
376348

377349
assert api_client.get_async_httpx_client() is main_client
378-
# The httpx client wrapper stays per-loop, but pyqwest's I/O runs on
379-
# its own Rust runtime, so every loop's client delegates to the same
380-
# shared transport.
381-
assert worker_client_id == worker_cached_client_id
382-
assert worker_client_id != id(main_client)
383-
assert worker_transport_id == id(main_client._transport)
350+
assert other_loop_client is main_client
384351
finally:
385352
await main_client.aclose()
386353
reset_async_api_transports()
@@ -415,7 +382,9 @@ async def get_transport():
415382
reset_async_api_transports()
416383

417384

418-
def test_async_api_client_not_reused_across_sequential_loops(test_api_key):
385+
def test_async_api_client_reused_across_sequential_loops(test_api_key):
386+
# A closed loop doesn't strand the client: nothing in it is loop-bound,
387+
# so a later loop reuses the same client and shared transport.
419388
reset_async_api_transports()
420389
config = ConnectionConfig(api_key=test_api_key)
421390
api_client = get_async_api_client(config)
@@ -429,22 +398,18 @@ async def get_client():
429398
loop_a = asyncio.new_event_loop()
430399
try:
431400
client_a = loop_a.run_until_complete(get_client())
432-
loop_a.run_until_complete(client_a.aclose())
433401
finally:
434402
loop_a.close()
435403
del loop_a
436404
gc.collect()
437405

438-
assert len(api_client._async_clients) == 0
439-
440406
loop_b = asyncio.new_event_loop()
441407
try:
442408
client_b = loop_b.run_until_complete(get_client())
443-
loop_b.run_until_complete(client_b.aclose())
444409
finally:
445410
loop_b.close()
446411

447-
assert client_b is not client_a
412+
assert client_b is client_a
448413
finally:
449414
reset_async_api_transports()
450415

@@ -517,6 +482,47 @@ def test_sync_api_client_round_trips_through_pyqwest(test_api_key, echo_server):
517482
reset_sync_api_transports()
518483

519484

485+
def test_sync_api_client_serves_concurrent_threads(test_api_key, echo_server):
486+
# The scenario the removed per-thread client caching used to guard: one
487+
# client, one shared pyqwest pool, many threads at once.
488+
reset_sync_api_transports()
489+
config = ConnectionConfig(api_key=test_api_key, api_url=echo_server)
490+
api_client = get_sync_api_client(config)
491+
httpx_client = api_client.get_httpx_client()
492+
493+
def request(i: int) -> tuple[int, str]:
494+
response = httpx_client.request("GET", f"/sandboxes/{i}")
495+
return response.status_code, response.json()["path"]
496+
497+
try:
498+
with ThreadPoolExecutor(max_workers=16) as executor:
499+
results = list(executor.map(request, range(32)))
500+
501+
assert results == [(200, f"/sandboxes/{i}") for i in range(32)]
502+
finally:
503+
httpx_client.close()
504+
reset_sync_api_transports()
505+
506+
507+
@pytest.mark.asyncio
508+
async def test_async_api_client_serves_concurrent_requests(test_api_key, echo_server):
509+
reset_async_api_transports()
510+
config = ConnectionConfig(api_key=test_api_key, api_url=echo_server)
511+
api_client = get_async_api_client(config)
512+
httpx_client = api_client.get_async_httpx_client()
513+
514+
async def request(i: int) -> tuple[int, str]:
515+
response = await httpx_client.request("GET", f"/sandboxes/{i}")
516+
return response.status_code, response.json()["path"]
517+
518+
try:
519+
results = await asyncio.gather(*(request(i) for i in range(32)))
520+
assert list(results) == [(200, f"/sandboxes/{i}") for i in range(32)]
521+
finally:
522+
await httpx_client.aclose()
523+
reset_async_api_transports()
524+
525+
520526
@pytest.mark.asyncio
521527
async def test_async_api_client_round_trips_through_pyqwest(test_api_key, echo_server):
522528
reset_async_api_transports()

0 commit comments

Comments
 (0)