Repository navigation
fix(python-sdk): download files over HTTP/1.1 to avoid h2 too_many_data_frames #2039
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
devin-ai-integration
wants to merge
4
commits into
main
Choose a base branch
from
devin/1793031570-h2-empty-data-frames
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
ae4ed1a
fix(python-sdk): download files over HTTP/1.1 to avoid h2 too_many_da…
devin-ai-integration[bot] ef0b2d4
test(python-sdk): sniff h2 preface without MSG_PEEK|MSG_WAITALL for W…
devin-ai-integration[bot] ad9450f
docs(python-sdk): note http_version is overridden for file downloads
devin-ai-integration[bot] 978aead
ci: retrigger (flaky test_auto_resume_wakes_on_http_request)
devin-ai-integration[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| --- | ||
| '@e2b/python-sdk': patch | ||
| --- | ||
|
|
||
| - Fixed `Sandbox.files.read` / `AsyncSandbox.files.read` failing at random for | ||
| files over ~500 KB (and always for a few MB) with `httpx.ReadError ... | ||
| too_many_data_frames`. File downloads now go over HTTP/1.1 regardless of | ||
| `http_version`, as h2 closed HTTP/2 connections after receiving 100 empty | ||
| DATA frames, which the sandbox proxy sends throughout file responses. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
190 changes: 190 additions & 0 deletions
190
packages/python-sdk/tests/test_envd_file_download_empty_data_frames.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| """File downloads must survive a peer that pads responses with empty DATA frames. | ||
|
|
||
| The sandbox proxy splits envd file downloads into HTTP/2 DATA frames with an | ||
| empty, non-final one after every ~32 KiB. h2 (under pyqwest) counts those over | ||
| the lifetime of a connection and tears the whole connection down with | ||
| ``ENHANCE_YOUR_CALM`` (``too_many_data_frames``) after 100 of them, so a few | ||
| MB read over HTTP/2 always failed, and smaller reads failed once a pooled | ||
| connection had accumulated the count. ``files.read`` therefore downloads over | ||
| HTTP/1.1. The server here answers HTTP/2 (prior knowledge, standing in for | ||
| ALPN against the real proxy) the way the proxy does, and plain HTTP/1.1 | ||
| otherwise. | ||
| """ | ||
|
|
||
| import contextlib | ||
| import socket | ||
| import threading | ||
| from typing import Iterator, List | ||
|
|
||
| import h2.config | ||
| import h2.connection | ||
| import h2.events | ||
| import pytest | ||
| from packaging.version import Version | ||
| from pyqwest import HTTPVersion | ||
|
|
||
| import e2b.api.client_async as api_client_async | ||
| import e2b.api.client_sync as api_client_sync | ||
| from e2b.connection_config import ConnectionConfig | ||
| from e2b.sandbox_async.filesystem.filesystem import Filesystem as AsyncFilesystem | ||
| from e2b.sandbox_sync.filesystem.filesystem import Filesystem | ||
| from transport_caches import reset_transport_caches | ||
|
|
||
| H2_PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" | ||
| # Comfortably past h2's limit of 100 empty DATA frames per connection, with | ||
| # chunks large enough not to touch its separate small-frame budget and a body | ||
| # that fits the default flow-control windows. | ||
| CHUNKS = [bytes([ord("a") + i % 26]) * 300 for i in range(150)] | ||
| CONTENT = b"".join(CHUNKS) | ||
|
|
||
|
|
||
| class EmptyDataFramesServer(threading.Thread): | ||
| def __init__(self): | ||
| super().__init__(daemon=True) | ||
| self.listener = socket.create_server(("127.0.0.1", 0)) | ||
| self.port = self.listener.getsockname()[1] | ||
| self.protocols: List[str] = [] | ||
|
|
||
| def run(self): | ||
| while True: | ||
| try: | ||
| sock, _ = self.listener.accept() | ||
| except OSError: | ||
| return | ||
| threading.Thread(target=self.serve, args=(sock,), daemon=True).start() | ||
|
|
||
| def serve(self, sock: socket.socket): | ||
| sock.settimeout(10) | ||
| try: | ||
| # Sniff without MSG_PEEK/MSG_WAITALL, which Windows can't combine. | ||
| buf = b"" | ||
| while len(buf) < len(H2_PREFACE) and H2_PREFACE.startswith(buf): | ||
| data = sock.recv(65535) | ||
| if not data: | ||
| return | ||
| buf += data | ||
| if buf.startswith(H2_PREFACE): | ||
| self.serve_h2(sock, buf) | ||
| else: | ||
| self.serve_h1(sock, buf) | ||
| except OSError: | ||
| pass | ||
| finally: | ||
| sock.close() | ||
|
|
||
| def serve_h1(self, sock: socket.socket, buf: bytes): | ||
| while True: | ||
| while b"\r\n\r\n" not in buf: | ||
| data = sock.recv(65535) | ||
| if not data: | ||
| return | ||
| buf += data | ||
| _, buf = buf.split(b"\r\n\r\n", 1) | ||
| self.protocols.append("HTTP/1.1") | ||
| sock.sendall( | ||
| b"HTTP/1.1 200 OK\r\n" | ||
| b"Content-Type: application/octet-stream\r\n" | ||
| + f"Content-Length: {len(CONTENT)}\r\n\r\n".encode() | ||
| + CONTENT | ||
| ) | ||
|
|
||
| def serve_h2(self, sock: socket.socket, data: bytes): | ||
| conn = h2.connection.H2Connection( | ||
| config=h2.config.H2Configuration(client_side=False) | ||
| ) | ||
| conn.initiate_connection() | ||
| sock.sendall(conn.data_to_send()) | ||
| while True: | ||
| for event in conn.receive_data(data): | ||
| if isinstance(event, h2.events.RequestReceived): | ||
| self.protocols.append("HTTP/2") | ||
| conn.send_headers( | ||
| event.stream_id, | ||
| [ | ||
| (":status", "200"), | ||
| ("content-type", "application/octet-stream"), | ||
| ("content-length", str(len(CONTENT))), | ||
| ], | ||
| ) | ||
| for chunk in CHUNKS: | ||
| conn.send_data(event.stream_id, chunk) | ||
| conn.send_data(event.stream_id, b"") | ||
| conn.end_stream(event.stream_id) | ||
| elif isinstance(event, h2.events.ConnectionTerminated): | ||
| return | ||
| out = conn.data_to_send() | ||
| if out: | ||
| sock.sendall(out) | ||
| data = sock.recv(65535) | ||
| if not data: | ||
| return | ||
|
|
||
|
|
||
| @contextlib.contextmanager | ||
| def empty_data_frames_server() -> Iterator[EmptyDataFramesServer]: | ||
| server = EmptyDataFramesServer() | ||
| server.start() | ||
| try: | ||
| yield server | ||
| finally: | ||
| server.listener.close() | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def negotiate_http2(monkeypatch): | ||
| """Speak HTTP/2 wherever production would negotiate it over TLS: the | ||
| server is plaintext, so force prior knowledge on every transport not | ||
| pinned to HTTP/1.1.""" | ||
| reset_transport_caches() | ||
| for module, name in ( | ||
| (api_client_sync, "SyncHTTPTransport"), | ||
| (api_client_async, "HTTPTransport"), | ||
| ): | ||
| build_transport = getattr(module, name) | ||
|
|
||
| def build(build_transport=build_transport, **kwargs): | ||
| if kwargs.get("http_version") is None: | ||
| kwargs["http_version"] = HTTPVersion.HTTP2 | ||
| return build_transport(**kwargs) | ||
|
|
||
| monkeypatch.setattr(module, name, build) | ||
| yield | ||
| reset_transport_caches() | ||
|
|
||
|
|
||
| ENVD_VERSION = Version("0.6.2") | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("http_version", ["2", "1.1"]) | ||
| def test_sync_read_downloads_over_http1(http_version): | ||
| config = ConnectionConfig(api_key="e2b_" + "0" * 40, http_version=http_version) | ||
| with empty_data_frames_server() as server: | ||
| url = f"http://127.0.0.1:{server.port}" | ||
| fs = Filesystem( | ||
| url, ENVD_VERSION, config, api_client_sync.get_envd_api(config, url) | ||
| ) | ||
| for _ in range(3): | ||
| assert fs.read("/blob", format="bytes") == CONTENT | ||
| assert fs.read("/blob", format="text") == CONTENT.decode() | ||
| with fs.read("/blob", format="stream") as reader: | ||
| assert b"".join(reader) == CONTENT | ||
| assert set(server.protocols) == {"HTTP/1.1"} | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("http_version", ["2", "1.1"]) | ||
| async def test_async_read_downloads_over_http1(http_version): | ||
| config = ConnectionConfig(api_key="e2b_" + "0" * 40, http_version=http_version) | ||
| with empty_data_frames_server() as server: | ||
| url = f"http://127.0.0.1:{server.port}" | ||
| fs = AsyncFilesystem( | ||
| url, ENVD_VERSION, config, api_client_async.get_envd_api(config, url) | ||
| ) | ||
| for _ in range(3): | ||
| assert await fs.read("/blob", format="bytes") == CONTENT | ||
| assert await fs.read("/blob", format="text") == CONTENT.decode() | ||
| for idle_timeout in (None, 5): | ||
| async with await fs.read( | ||
| "/blob", format="stream", stream_idle_timeout=idle_timeout | ||
| ) as reader: | ||
| assert b"".join([chunk async for chunk in reader]) == CONTENT | ||
| assert set(server.protocols) == {"HTTP/1.1"} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 (optional) High-concurrency processes now stall or time out on
files.readpast 200 concurrent downloads per edge address. Before, HTTP/2 multiplexing allowed about 20,000. The pinned HTTP/1.1 pools keepmax_connections_per_address=max_connections(default 200). That cap was sized for 100 streams per connection, and every sandbox resolves to the shared envd edge. Fix: size the HTTP/1.1 download pools separately from the HTTP/2 cap (their own larger limit or env var). Update the README section that says files traffic shares the HTTP/2 pool. Non-stream reads then fail afterREQUEST_TIMEOUT60s, and streamed reads wait with no default bound.Why this was flagged
An eval-style process runs
sandbox.files.readconcurrently across many sandboxes. Before this change each read was one HTTP/2 stream. pyqwest packs up to 100 streams per connection, capped at 200 connections per resolved address (e2b/api/init.py:98), so about 20,000 concurrent reads were possible. NowENVD_API_FILE_DOWNLOAD_HTTP_VERSION = "1.1"(e2b/envd/api.py:31) routes them to pools built withHTTPVersion.HTTP1and the samemax_connections_per_address=max_connections(client_async/init.py:131, client_sync/init.py:136). Each download holds a whole connection, and all sandboxes share the edge address. So the 201st concurrent non-stream read waits for a connection and fails once the 60sREQUEST_TIMEOUTwhole-request deadline passes. A streamed read sendsstream_timeout=Noneunlessrequest_timeoutis set (sandbox_async/filesystem/filesystem.py:243), so it waits until another caller closes its reader. README.md:71 still says files traffic shares the HTTP/2 pool.Verification:
envd/api.py:31setsENVD_API_FILE_DOWNLOAD_HTTP_VERSION = "1.1". That builds a separateSyncHTTPTransportwithmax_connections_per_address=max_connections(client_sync__init__.py:136) andhttp_version=HTTPVersion.HTTP1(line 141).max_connectionsdefaults to 200 (e2b/api/__init__.py:98). HTTP/1.1 carries one request per connection.