Skip to content

Commit 98d2c8c

Browse files
djeebusValentaTomasmishushakov
authored
Ensure that httpx transport is reused across calls by default (#997)
This takes some shortcuts in order to keep backwards compatibility. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Reuses a singleton httpx transport/client across the SDK, introduces a generic retry decorator for connect calls, and refactors code/tests to use the shared clients and fixtures. > > - **SDK (transport/client reuse)** > - Add global `limits` (configurable via `E2B_*` env vars) and switch `ApiClient` to accept `transport` instead of `limits`. > - Introduce `e2b.api.client_async/client_sync` with `get_transport()` (singleton) and `get_api_client()`; update `Sandbox`/`AsyncSandbox`, paginators, and sandbox APIs to use them. > - Remove per-class `_limits` from `SandboxBase`/`TemplateBase`; merge extra headers correctly; pass `E2b-Sandbox-Port` as string. > - Template build flows (sync/async) now reuse the API client's underlying httpx client for file uploads. > - **Connect client** (`e2b_connect/client.py`) > - Add `_retry` decorator and apply to unary/server-stream methods; simplify reconnection logic; minor typing/headers cleanups. > - **Tests** > - Add retry unit tests; introduce `sandbox_factory`/`async_sandbox_factory` and a session `event_loop`; refactor tests to use factories and shared transports; adjust tar archive expectations. > - **Misc** > - Add `.editorconfig`, CLI `.envrc`, and changeset entries. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1d35923. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Tomas Valenta <[email protected]> Co-authored-by: Mish <[email protected]>
1 parent bbeff74 commit 98d2c8c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1161
-1071
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@e2b/cli": patch
3+
"@e2b/python-sdk": patch
4+
---
5+
6+
Ensure that httpx transport is reused across calls by default

.editorconfig

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
root = true
2+
3+
[*.py]
4+
indent_size = 4

packages/cli/.envrc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
source_up
2+
3+
dotenv ../../../.env.local

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
import os
12
from types import TracebackType
23
import json
34
import logging
4-
from typing import Optional
5-
from httpx import Limits
5+
from typing import Optional, Union
6+
from httpx import Limits, BaseTransport, AsyncBaseTransport
67
from dataclasses import dataclass
78

8-
99
from e2b.api.client.client import AuthenticatedClient
1010
from e2b.connection_config import ConnectionConfig
1111
from e2b.api.metadata import default_headers
@@ -18,6 +18,12 @@
1818

1919
logger = logging.getLogger(__name__)
2020

21+
limits = Limits(
22+
max_keepalive_connections=int(os.getenv("E2B_MAX_KEEPALIVE_CONNECTIONS", "20")),
23+
max_connections=int(os.getenv("E2B_MAX_CONNECTIONS", "2000")),
24+
keepalive_expiry=int(os.getenv("E2B_KEEPALIVE_EXPIRY", "300")),
25+
)
26+
2127

2228
@dataclass
2329
class SandboxCreateResponse:
@@ -68,7 +74,7 @@ def __init__(
6874
config: ConnectionConfig,
6975
require_api_key: bool = True,
7076
require_access_token: bool = False,
71-
limits: Optional[Limits] = None,
77+
transport: Optional[Union[BaseTransport, AsyncBaseTransport]] = None,
7278
*args,
7379
**kwargs,
7480
):
@@ -109,7 +115,9 @@ def __init__(
109115
}
110116

111117
# Prevent passing these parameters twice
112-
kwargs.pop("headers", None)
118+
more_headers: Optional[dict] = kwargs.pop("headers", None)
119+
if more_headers:
120+
headers.update(more_headers)
113121
kwargs.pop("token", None)
114122
kwargs.pop("auth_header_name", None)
115123
kwargs.pop("prefix", None)
@@ -122,7 +130,7 @@ def __init__(
122130
"response": [self._log_response],
123131
},
124132
"proxy": config.proxy,
125-
"limits": limits,
133+
"transport": transport,
126134
},
127135
headers=headers,
128136
token=token,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import httpx
2+
import logging
3+
4+
from typing import Optional
5+
6+
from typing_extensions import Self
7+
8+
from e2b.connection_config import ConnectionConfig
9+
from e2b.api import limits, AsyncApiClient
10+
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient:
16+
return AsyncApiClient(
17+
config,
18+
transport=get_transport(config),
19+
**kwargs,
20+
)
21+
22+
23+
class AsyncTransportWithLogger(httpx.AsyncHTTPTransport):
24+
singleton: Optional[Self] = None
25+
26+
async def handle_async_request(self, request):
27+
url = f"{request.url.scheme}://{request.url.host}{request.url.path}"
28+
logger.info(f"Request: {request.method} {url}")
29+
response = await super().handle_async_request(request)
30+
31+
# data = connect.GzipCompressor.decompress(response.read()).decode()
32+
logger.info(f"Response: {response.status_code} {url}")
33+
34+
return response
35+
36+
@property
37+
def pool(self):
38+
return self._pool
39+
40+
41+
def get_transport(config: ConnectionConfig) -> AsyncTransportWithLogger:
42+
if AsyncTransportWithLogger.singleton is not None:
43+
return AsyncTransportWithLogger.singleton
44+
45+
transport = AsyncTransportWithLogger(
46+
limits=limits,
47+
proxy=config.proxy,
48+
)
49+
AsyncTransportWithLogger.singleton = transport
50+
return transport
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from typing import Optional
2+
3+
import httpx
4+
import logging
5+
6+
from typing_extensions import Self
7+
8+
from e2b.api import ApiClient, limits
9+
from e2b.connection_config import ConnectionConfig
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient:
15+
return ApiClient(
16+
config,
17+
transport=get_transport(config),
18+
**kwargs,
19+
)
20+
21+
22+
class TransportWithLogger(httpx.HTTPTransport):
23+
singleton: Optional[Self] = None
24+
25+
def handle_request(self, request):
26+
url = f"{request.url.scheme}://{request.url.host}{request.url.path}"
27+
logger.info(f"Request: {request.method} {url}")
28+
response = super().handle_request(request)
29+
30+
# data = connect.GzipCompressor.decompress(response.read()).decode()
31+
logger.info(f"Response: {response.status_code} {url}")
32+
33+
return response
34+
35+
@property
36+
def pool(self):
37+
return self._pool
38+
39+
40+
_transport: Optional[TransportWithLogger] = None
41+
42+
43+
def get_transport(config: ConnectionConfig) -> TransportWithLogger:
44+
if TransportWithLogger.singleton is not None:
45+
return TransportWithLogger.singleton
46+
47+
transport = TransportWithLogger(
48+
limits=limits,
49+
proxy=config.proxy,
50+
)
51+
TransportWithLogger.singleton = transport
52+
return transport

packages/python-sdk/e2b/connection_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22

33
from typing import Optional, Dict, TypedDict
4+
45
from httpx._types import ProxyTypes
56
from typing_extensions import Unpack
67

packages/python-sdk/e2b/sandbox/main.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from e2b.connection_config import ConnectionConfig, default_username
88
from e2b.envd.api import ENVD_API_FILES_ROUTE
99
from e2b.envd.versions import ENVD_DEFAULT_USER
10-
from httpx import Limits
1110

1211

1312
class SandboxOpts(TypedDict):
@@ -20,12 +19,6 @@ class SandboxOpts(TypedDict):
2019

2120

2221
class SandboxBase:
23-
_limits = Limits(
24-
max_keepalive_connections=40,
25-
max_connections=40,
26-
keepalive_expiry=300,
27-
)
28-
2922
mcp_port = 50005
3023

3124
default_sandbox_timeout = 300

packages/python-sdk/e2b/sandbox_async/main.py

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,26 +21,11 @@
2121
from e2b.sandbox_async.commands.command import Commands
2222
from e2b.sandbox_async.commands.pty import Pty
2323
from e2b.sandbox_async.sandbox_api import SandboxApi, SandboxInfo
24+
from e2b.api.client_async import get_transport
2425

2526
logger = logging.getLogger(__name__)
2627

2728

28-
class AsyncTransportWithLogger(httpx.AsyncHTTPTransport):
29-
async def handle_async_request(self, request):
30-
url = f"{request.url.scheme}://{request.url.host}{request.url.path}"
31-
logger.info(f"Request: {request.method} {url}")
32-
response = await super().handle_async_request(request)
33-
34-
# data = connect.GzipCompressor.decompress(response.read()).decode()
35-
logger.info(f"Response: {response.status_code} {url}")
36-
37-
return response
38-
39-
@property
40-
def pool(self):
41-
return self._pool
42-
43-
4429
class AsyncSandbox(SandboxApi):
4530
"""
4631
E2B cloud sandbox is a secure and isolated cloud environment.
@@ -85,15 +70,16 @@ def pty(self) -> Pty:
8570
"""
8671
return self._pty
8772

88-
def __init__(self, **opts: Unpack[SandboxOpts]):
73+
def __init__(
74+
self,
75+
**opts: Unpack[SandboxOpts],
76+
):
8977
"""
9078
Use `AsyncSandbox.create()` to create a new sandbox instead.
9179
"""
9280
super().__init__(**opts)
9381

94-
self._transport = AsyncTransportWithLogger(
95-
limits=self._limits, proxy=self.connection_config.proxy
96-
)
82+
self._transport = get_transport(self.connection_config)
9783
self._envd_api = httpx.AsyncClient(
9884
base_url=self.connection_config.get_sandbox_url(
9985
self.sandbox_id, self.sandbox_domain

packages/python-sdk/e2b/sandbox_async/paginator.py

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
from e2b.api.client.api.sandboxes import get_v2_sandboxes
55
from e2b.api.client.types import UNSET
66
from e2b.exceptions import SandboxException
7-
from e2b.sandbox.main import SandboxBase
87
from e2b.sandbox.sandbox_api import SandboxPaginatorBase, SandboxInfo
9-
from e2b.api import AsyncApiClient, handle_api_exception
8+
from e2b.api import handle_api_exception
109
from e2b.api.client.models.error import Error
10+
from e2b.api.client_async import get_api_client
1111

1212

1313
class AsyncSandboxPaginator(SandboxPaginatorBase):
@@ -44,29 +44,26 @@ async def next_items(self) -> List[SandboxInfo]:
4444
}
4545
metadata = urllib.parse.urlencode(quoted_metadata)
4646

47-
async with AsyncApiClient(
48-
self._config,
49-
limits=SandboxBase._limits,
50-
) as api_client:
51-
res = await get_v2_sandboxes.asyncio_detailed(
52-
client=api_client,
53-
metadata=metadata if metadata else UNSET,
54-
state=self.query.state if self.query and self.query.state else UNSET,
55-
limit=self.limit if self.limit else UNSET,
56-
next_token=self._next_token if self._next_token else UNSET,
57-
)
47+
api_client = get_api_client(self._config)
48+
res = await get_v2_sandboxes.asyncio_detailed(
49+
client=api_client,
50+
metadata=metadata if metadata else UNSET,
51+
state=self.query.state if self.query and self.query.state else UNSET,
52+
limit=self.limit if self.limit else UNSET,
53+
next_token=self._next_token if self._next_token else UNSET,
54+
)
5855

59-
if res.status_code >= 300:
60-
raise handle_api_exception(res)
56+
if res.status_code >= 300:
57+
raise handle_api_exception(res)
6158

62-
self._next_token = res.headers.get("x-next-token")
63-
self._has_next = bool(self._next_token)
59+
self._next_token = res.headers.get("x-next-token")
60+
self._has_next = bool(self._next_token)
6461

65-
if res.parsed is None:
66-
return []
62+
if res.parsed is None:
63+
return []
6764

68-
# Check if res.parse is Error
69-
if isinstance(res.parsed, Error):
70-
raise SandboxException(f"{res.parsed.message}: Request failed")
65+
# Check if res.parse is Error
66+
if isinstance(res.parsed, Error):
67+
raise SandboxException(f"{res.parsed.message}: Request failed")
7168

72-
return [SandboxInfo._from_listed_sandbox(sandbox) for sandbox in res.parsed]
69+
return [SandboxInfo._from_listed_sandbox(sandbox) for sandbox in res.parsed]

0 commit comments

Comments
 (0)