Skip to content

Commit 6c04e31

Browse files
fix(python-sdk): use thread-local API transports (#1399)
makes the sync python python API http transport cache thread-local to handle unsafe usage of the shared transport under pressure (e.g. concurrent template builds). uses the same logic that we were using for envd. test `test_sync_api_transport_cache_reuses_within_thread_and_isolates_across_threads` fails on main, passes on branch.
1 parent 08012ee commit 6c04e31

3 files changed

Lines changed: 56 additions & 19 deletions

File tree

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+
Use thread-local sync HTTP transports to avoid sharing HTTP/2 connection state across Python threads.

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient:
1919

2020

2121
class TransportWithLogger(httpx.HTTPTransport):
22-
_instances: Dict[bool, "TransportWithLogger"] = {}
22+
_thread_local = threading.local()
2323

2424
def handle_request(self, request):
2525
url = f"{request.url.scheme}://{request.url.host}{request.url.path}"
@@ -37,7 +37,10 @@ def pool(self):
3737

3838

3939
def get_transport(config: ConnectionConfig, http2: bool = True) -> TransportWithLogger:
40-
cached = TransportWithLogger._instances.get(http2)
40+
instances: Dict[bool, TransportWithLogger] = getattr(
41+
TransportWithLogger._thread_local, "instances", {}
42+
)
43+
cached = instances.get(http2)
4144
if cached is not None:
4245
return cached
4346

@@ -46,7 +49,8 @@ def get_transport(config: ConnectionConfig, http2: bool = True) -> TransportWith
4649
proxy=config.proxy,
4750
http2=http2,
4851
)
49-
TransportWithLogger._instances[http2] = transport
52+
instances[http2] = transport
53+
TransportWithLogger._thread_local.instances = instances
5054
return transport
5155

5256

packages/python-sdk/tests/test_api_client_transport.py

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,21 @@
1414
from e2b.connection_config import ConnectionConfig
1515

1616

17+
def reset_sync_api_transports():
18+
TransportWithLogger._thread_local.instances = {}
19+
20+
21+
def reset_sync_envd_transports():
22+
EnvdTransportWithLogger._thread_local.instances = {}
23+
24+
25+
def run_in_worker_thread(fn):
26+
with ThreadPoolExecutor(max_workers=1) as executor:
27+
return executor.submit(fn).result()
28+
29+
1730
def test_sync_api_client_proxy_uses_explicit_transport(test_api_key):
18-
TransportWithLogger._instances.clear()
31+
reset_sync_api_transports()
1932
config = ConnectionConfig(
2033
api_key=test_api_key,
2134
proxy="http://127.0.0.1:9999",
@@ -26,15 +39,15 @@ def test_sync_api_client_proxy_uses_explicit_transport(test_api_key):
2639

2740
try:
2841
assert "proxy" not in api_client._httpx_args
29-
assert httpx_client._transport is TransportWithLogger._instances[True]
42+
assert httpx_client._transport is get_sync_transport(config)
3043
assert httpx_client._mounts == {}
3144
finally:
3245
httpx_client.close()
33-
TransportWithLogger._instances.clear()
46+
reset_sync_api_transports()
3447

3548

3649
def test_sync_get_transport_http2_opt_out_returns_distinct_instance(test_api_key):
37-
TransportWithLogger._instances.clear()
50+
reset_sync_api_transports()
3851
config = ConnectionConfig(api_key=test_api_key)
3952

4053
try:
@@ -49,12 +62,12 @@ def test_sync_get_transport_http2_opt_out_returns_distinct_instance(test_api_key
4962
assert get_sync_transport(config) is http2_transport
5063
assert get_sync_transport(config, http2=False) is http1_transport
5164
finally:
52-
TransportWithLogger._instances.clear()
65+
reset_sync_api_transports()
5366

5467

5568
def test_sync_envd_transport_uses_separate_cache(test_api_key):
56-
TransportWithLogger._instances.clear()
57-
EnvdTransportWithLogger._thread_local.instances = {}
69+
reset_sync_api_transports()
70+
reset_sync_envd_transports()
5871
config = ConnectionConfig(api_key=test_api_key)
5972

6073
try:
@@ -66,28 +79,43 @@ def test_sync_envd_transport_uses_separate_cache(test_api_key):
6679
assert get_sync_envd_transport(config) is envd_transport
6780
assert envd_transport._pool._http2 is True
6881
finally:
69-
TransportWithLogger._instances.clear()
70-
EnvdTransportWithLogger._thread_local.instances = {}
82+
reset_sync_api_transports()
83+
reset_sync_envd_transports()
7184

7285

73-
def test_sync_envd_transport_cache_is_thread_local(test_api_key):
74-
EnvdTransportWithLogger._thread_local.instances = {}
86+
def test_sync_api_transport_cache_reuses_within_thread_and_isolates_across_threads(
87+
test_api_key,
88+
):
89+
reset_sync_api_transports()
7590
config = ConnectionConfig(api_key=test_api_key)
7691

77-
def get_thread_transport():
78-
return get_sync_envd_transport(config)
92+
try:
93+
main_transport = get_sync_transport(config)
94+
same_thread_transport = get_sync_transport(config)
95+
worker_thread_transport = run_in_worker_thread(
96+
lambda: get_sync_transport(config)
97+
)
98+
99+
assert same_thread_transport is main_transport
100+
assert worker_thread_transport is not main_transport
101+
finally:
102+
reset_sync_api_transports()
103+
104+
105+
def test_sync_envd_transport_cache_is_thread_local(test_api_key):
106+
reset_sync_envd_transports()
107+
config = ConnectionConfig(api_key=test_api_key)
79108

80109
try:
81110
main_transport = get_sync_envd_transport(config)
82-
with ThreadPoolExecutor(max_workers=1) as executor:
83-
thread_transport = executor.submit(get_thread_transport).result()
111+
thread_transport = run_in_worker_thread(lambda: get_sync_envd_transport(config))
84112

85113
assert main_transport is get_sync_envd_transport(config)
86114
assert thread_transport is not main_transport
87115
assert main_transport._pool._http2 is True
88116
assert thread_transport._pool._http2 is True
89117
finally:
90-
EnvdTransportWithLogger._thread_local.instances = {}
118+
reset_sync_envd_transports()
91119

92120

93121
@pytest.mark.asyncio

0 commit comments

Comments
 (0)