Skip to content

Commit 32880d6

Browse files
mishushakovclaude
andauthored
fix(python-sdk): key async transport caches by loop object, not id(loop) (#1434)
## Description The per-event-loop caches for `AsyncHTTPTransport`s and `httpx.AsyncClient`s (Sandbox API, envd, and volume clients) were keyed by `id(asyncio.get_running_loop())`, but CPython reuses object ids of dead loops almost immediately — so sequential loops could inherit a transport bound to a previous, closed loop and fail. The caches are now `weakref.WeakKeyDictionary`s keyed by the loop object itself, which makes stale id collisions impossible and releases entries when their loop is garbage collected (fixing a leak where dead-loop entries accumulated forever). Added regression tests covering the sequential-loop scenario for all three caches. This pattern no longer breaks: ```python # e.g. a worker or test harness running repeated event loops for job in jobs: asyncio.run(process_with_sandbox(job)) # each run previously risked # inheriting a closed loop's transport ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0b0c728 commit 32880d6

6 files changed

Lines changed: 176 additions & 51 deletions

File tree

.changeset/eager-loops-retire.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@e2b/python-sdk": patch
3+
---
4+
5+
Key per-event-loop async HTTP transport and client caches by the loop object (held weakly) instead of `id(loop)`. CPython can reuse the id of a closed loop almost immediately, so sequential event loops (for example repeated `asyncio.run(...)` calls) could inherit a transport or client bound to a previous, closed loop. Cache entries are now also released automatically when their loop is garbage collected.

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
import os
55
import re
66
import threading
7+
import weakref
78
from dataclasses import dataclass
89
from types import TracebackType
9-
from typing import Callable, Dict, Optional, Protocol, Union
10+
from typing import Callable, Optional, Protocol, Union
1011

1112
import httpx
1213
from httpx import AsyncBaseTransport, BaseTransport, Limits, Timeout
@@ -120,7 +121,12 @@ def __init__(
120121
self._transport_factory = transport_factory
121122
self._async_transport_factory = async_transport_factory
122123
self._thread_local = threading.local()
123-
self._async_clients: Dict[int, httpx.AsyncClient] = {}
124+
# Keyed weakly by the event loop object itself, not id(loop) —
125+
# CPython reuses object ids, so a new loop could otherwise inherit
126+
# a client bound to a previous, closed loop.
127+
self._async_clients: weakref.WeakKeyDictionary[
128+
asyncio.AbstractEventLoop, httpx.AsyncClient
129+
] = weakref.WeakKeyDictionary()
124130
self._proxy = config.proxy
125131

126132
if require_api_key and require_access_token:
@@ -231,8 +237,8 @@ def get_async_httpx_client(self) -> httpx.AsyncClient:
231237
if self._async_client is not None or self._async_transport_factory is None:
232238
return super().get_async_httpx_client()
233239

234-
loop_id = id(asyncio.get_running_loop())
235-
client = self._async_clients.get(loop_id)
240+
loop = asyncio.get_running_loop()
241+
client = self._async_clients.get(loop)
236242
if client is None:
237243
client = httpx.AsyncClient(
238244
base_url=self._base_url,
@@ -244,7 +250,7 @@ def get_async_httpx_client(self) -> httpx.AsyncClient:
244250
event_hooks=self._httpx_args.get("event_hooks"),
245251
transport=self._async_transport_factory(),
246252
)
247-
self._async_clients[loop_id] = client
253+
self._async_clients[loop] = client
248254
return client
249255

250256
def _log_request(self, request):
Lines changed: 36 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import logging
3+
import weakref
34
from typing import Dict, Optional, Tuple
45

56
import httpx
@@ -11,7 +12,7 @@
1112

1213
logger = logging.getLogger(__name__)
1314

14-
TransportKey = Tuple[int, bool, Optional[ProxyTypes]]
15+
TransportKey = Tuple[bool, Optional[ProxyTypes]]
1516

1617

1718
def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient:
@@ -23,7 +24,13 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient:
2324

2425

2526
class AsyncTransportWithLogger(httpx.AsyncHTTPTransport):
26-
_instances: Dict[TransportKey, "AsyncTransportWithLogger"] = {}
27+
# Keyed weakly by the event loop object itself, not id(loop) — CPython
28+
# reuses object ids, so a new loop could otherwise inherit a transport
29+
# bound to a previous, closed loop.
30+
_instances: weakref.WeakKeyDictionary[
31+
asyncio.AbstractEventLoop,
32+
Dict[TransportKey, "AsyncTransportWithLogger"],
33+
] = weakref.WeakKeyDictionary()
2734

2835
async def handle_async_request(self, request):
2936
url = f"{request.url.scheme}://{request.url.host}{request.url.path}"
@@ -40,43 +47,41 @@ def pool(self):
4047
return self._pool
4148

4249

43-
def get_transport(
44-
config: ConnectionConfig, http2: bool = True
45-
) -> AsyncTransportWithLogger:
46-
key: TransportKey = (id(asyncio.get_running_loop()), http2, config.proxy)
50+
def _get_cached_transport(cls, config: ConnectionConfig, http2: bool):
51+
loop = asyncio.get_running_loop()
52+
loop_instances = cls._instances.get(loop)
53+
if loop_instances is None:
54+
loop_instances = {}
55+
cls._instances[loop] = loop_instances
56+
57+
key: TransportKey = (http2, config.proxy)
58+
transport = loop_instances.get(key)
59+
if transport is None:
60+
transport = cls(
61+
limits=limits,
62+
proxy=config.proxy,
63+
http2=http2,
64+
retries=connection_retries,
65+
)
66+
loop_instances[key] = transport
4767

48-
if key in AsyncTransportWithLogger._instances:
49-
return AsyncTransportWithLogger._instances[key]
68+
return transport
5069

51-
transport = AsyncTransportWithLogger(
52-
limits=limits,
53-
proxy=config.proxy,
54-
http2=http2,
55-
retries=connection_retries,
56-
)
5770

58-
AsyncTransportWithLogger._instances[key] = transport
59-
return transport
71+
def get_transport(
72+
config: ConnectionConfig, http2: bool = True
73+
) -> AsyncTransportWithLogger:
74+
return _get_cached_transport(AsyncTransportWithLogger, config, http2)
6075

6176

6277
class AsyncEnvdTransportWithLogger(AsyncTransportWithLogger):
63-
_instances: Dict[TransportKey, "AsyncEnvdTransportWithLogger"] = {}
78+
_instances: weakref.WeakKeyDictionary[
79+
asyncio.AbstractEventLoop,
80+
Dict[TransportKey, "AsyncEnvdTransportWithLogger"],
81+
] = weakref.WeakKeyDictionary()
6482

6583

6684
def get_envd_transport(
6785
config: ConnectionConfig, http2: bool = True
6886
) -> AsyncEnvdTransportWithLogger:
69-
key: TransportKey = (id(asyncio.get_running_loop()), http2, config.proxy)
70-
71-
if key in AsyncEnvdTransportWithLogger._instances:
72-
return AsyncEnvdTransportWithLogger._instances[key]
73-
74-
transport = AsyncEnvdTransportWithLogger(
75-
limits=limits,
76-
proxy=config.proxy,
77-
http2=http2,
78-
retries=connection_retries,
79-
)
80-
81-
AsyncEnvdTransportWithLogger._instances[key] = transport
82-
return transport
87+
return _get_cached_transport(AsyncEnvdTransportWithLogger, config, http2)

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

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import asyncio
22
import logging
33
import os
4-
from typing import Dict, Optional, Tuple
4+
import weakref
5+
from typing import Dict, Optional
56

67
import httpx
78
from httpx import Limits
@@ -20,7 +21,7 @@
2021
keepalive_expiry=int(os.getenv("E2B_KEEPALIVE_EXPIRY", "300")),
2122
)
2223

23-
TransportKey = Tuple[int, Optional[ProxyTypes]]
24+
TransportKey = Optional[ProxyTypes]
2425

2526

2627
def get_api_client(config: VolumeConnectionConfig, **kwargs) -> AsyncVolumeApiClient:
@@ -53,7 +54,13 @@ def get_api_client(config: VolumeConnectionConfig, **kwargs) -> AsyncVolumeApiCl
5354

5455

5556
class AsyncTransportWithLogger(httpx.AsyncHTTPTransport):
56-
_instances: Dict[TransportKey, "AsyncTransportWithLogger"] = {}
57+
# Keyed weakly by the event loop object itself, not id(loop) — CPython
58+
# reuses object ids, so a new loop could otherwise inherit a transport
59+
# bound to a previous, closed loop.
60+
_instances: weakref.WeakKeyDictionary[
61+
asyncio.AbstractEventLoop,
62+
Dict[TransportKey, "AsyncTransportWithLogger"],
63+
] = weakref.WeakKeyDictionary()
5764

5865
async def handle_async_request(self, request):
5966
url = f"{request.url.scheme}://{request.url.host}{request.url.path}"
@@ -68,14 +75,19 @@ def pool(self):
6875

6976

7077
def get_transport(config: VolumeConnectionConfig) -> AsyncTransportWithLogger:
71-
key: TransportKey = (id(asyncio.get_running_loop()), config.proxy)
72-
73-
if key in AsyncTransportWithLogger._instances:
74-
return AsyncTransportWithLogger._instances[key]
78+
loop = asyncio.get_running_loop()
79+
loop_instances = AsyncTransportWithLogger._instances.get(loop)
80+
if loop_instances is None:
81+
loop_instances = {}
82+
AsyncTransportWithLogger._instances[loop] = loop_instances
83+
84+
key: TransportKey = config.proxy
85+
transport = loop_instances.get(key)
86+
if transport is None:
87+
transport = AsyncTransportWithLogger(
88+
limits=limits,
89+
proxy=config.proxy,
90+
)
91+
loop_instances[key] = transport
7592

76-
transport = AsyncTransportWithLogger(
77-
limits=limits,
78-
proxy=config.proxy,
79-
)
80-
AsyncTransportWithLogger._instances[key] = transport
8193
return transport

packages/python-sdk/tests/test_api_client_transport.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import gc
23
from concurrent.futures import ThreadPoolExecutor
34

45
import httpx
@@ -222,8 +223,8 @@ async def test_async_api_client_proxy_uses_explicit_transport(test_api_key):
222223

223224
api_client = get_async_api_client(config)
224225
httpx_client = api_client.get_async_httpx_client()
225-
transport = AsyncTransportWithLogger._instances[
226-
(id(asyncio.get_running_loop()), True, "http://127.0.0.1:9999")
226+
transport = AsyncTransportWithLogger._instances[asyncio.get_running_loop()][
227+
(True, "http://127.0.0.1:9999")
227228
]
228229

229230
try:
@@ -335,6 +336,72 @@ async def get_client_ids():
335336
AsyncTransportWithLogger._instances.clear()
336337

337338

339+
def test_async_transport_not_reused_across_sequential_loops(test_api_key):
340+
AsyncTransportWithLogger._instances.clear()
341+
config = ConnectionConfig(api_key=test_api_key)
342+
343+
async def get_transport():
344+
return get_async_transport(config)
345+
346+
try:
347+
loop_a = asyncio.new_event_loop()
348+
try:
349+
transport_a = loop_a.run_until_complete(get_transport())
350+
finally:
351+
loop_a.close()
352+
del loop_a
353+
gc.collect()
354+
355+
# The cache entry dies with the loop, so a later loop can never
356+
# inherit a transport bound to a closed loop, even when CPython
357+
# reuses the dead loop's object id.
358+
assert len(AsyncTransportWithLogger._instances) == 0
359+
360+
loop_b = asyncio.new_event_loop()
361+
try:
362+
transport_b = loop_b.run_until_complete(get_transport())
363+
finally:
364+
loop_b.close()
365+
366+
assert transport_b is not transport_a
367+
finally:
368+
AsyncTransportWithLogger._instances.clear()
369+
370+
371+
def test_async_api_client_not_reused_across_sequential_loops(test_api_key):
372+
AsyncTransportWithLogger._instances.clear()
373+
config = ConnectionConfig(api_key=test_api_key)
374+
api_client = get_async_api_client(config)
375+
376+
async def get_client():
377+
client = api_client.get_async_httpx_client()
378+
assert api_client.get_async_httpx_client() is client
379+
return client
380+
381+
try:
382+
loop_a = asyncio.new_event_loop()
383+
try:
384+
client_a = loop_a.run_until_complete(get_client())
385+
loop_a.run_until_complete(client_a.aclose())
386+
finally:
387+
loop_a.close()
388+
del loop_a
389+
gc.collect()
390+
391+
assert len(api_client._async_clients) == 0
392+
393+
loop_b = asyncio.new_event_loop()
394+
try:
395+
client_b = loop_b.run_until_complete(get_client())
396+
loop_b.run_until_complete(client_b.aclose())
397+
finally:
398+
loop_b.close()
399+
400+
assert client_b is not client_a
401+
finally:
402+
AsyncTransportWithLogger._instances.clear()
403+
404+
338405
@pytest.mark.asyncio
339406
async def test_async_envd_transport_uses_separate_cache(test_api_key):
340407
AsyncTransportWithLogger._instances.clear()

packages/python-sdk/tests/test_volume_client.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import asyncio
2+
import gc
23
import threading
34

45
import httpx
56
import pytest
67

78
from e2b.exceptions import AuthenticationException
89
from e2b.volume.client_async import (
10+
AsyncTransportWithLogger as AsyncVolumeTransport,
911
get_api_client as get_async_api_client,
1012
get_transport as get_async_transport,
1113
)
@@ -96,7 +98,6 @@ async def get_transports():
9698
async def get_proxied_transport():
9799
return get_async_transport(proxied)
98100

99-
# Keep both loops alive at the same time so their ids cannot collide
100101
loop_a = asyncio.new_event_loop()
101102
loop_b = asyncio.new_event_loop()
102103
try:
@@ -113,3 +114,32 @@ async def get_proxied_transport():
113114
finally:
114115
loop_a.close()
115116
loop_b.close()
117+
118+
119+
def test_async_transport_not_reused_across_sequential_loops():
120+
AsyncVolumeTransport._instances.clear()
121+
config = VolumeConnectionConfig(token="vol-token")
122+
123+
async def get_transport():
124+
return get_async_transport(config)
125+
126+
loop_a = asyncio.new_event_loop()
127+
try:
128+
transport_a = loop_a.run_until_complete(get_transport())
129+
finally:
130+
loop_a.close()
131+
del loop_a
132+
gc.collect()
133+
134+
# The cache entry dies with the loop, so a later loop can never inherit
135+
# a transport bound to a closed loop, even when CPython reuses the dead
136+
# loop's object id.
137+
assert len(AsyncVolumeTransport._instances) == 0
138+
139+
loop_b = asyncio.new_event_loop()
140+
try:
141+
transport_b = loop_b.run_until_complete(get_transport())
142+
finally:
143+
loop_b.close()
144+
145+
assert transport_b is not transport_a

0 commit comments

Comments
 (0)