fix(python-sdk): replay streamed requests whose HTTP/2 stream was refused by a GOAWAY - #1930
devin-ai-integration[bot] wants to merge 4 commits into
Conversation
… GOAWAY Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
🦋 Changeset detectedLatest commit: 81bfce2 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
TASTE.md review: checked parity (T-1, T-2), naming/enums/types (T-12, T-15, T-17), config and defaults (T-47, T-49, T-50), and docstrings (T-69, T-71). 0 violations. The change is internal transport plumbing with no public API surface. The sync and async _ReplayableContent / BalancingTransport mirror each other, StreamErrorCode is compared through its members, and replay_buffer_limit is a named module constant.
Not tied to a line:
- T-1 (parity, to confirm): the JS SDK has no matching replay for GOAWAY/
REFUSED_STREAM-refused streams (packages/js-sdk/src/retry.tsretries only connect-phase errors). If undici doesn't already re-dispatch these, a request crossing a connection's max-age GOAWAY will fail in JS but succeed in Python. Either confirm undici covers it or track a JS follow-up. - Outside TASTE.md:
.changeset/retry-refused-h2-streams.mdbumps"e2b"(the JS package), but this change only touches Python. It should probably be"@e2b/python-sdk".
Package ArtifactsBuilt from a67a88c. Download artifacts from this workflow run. JS SDK ( npm install ./e2b-2.52.1-devin-1790937196-retry-refused-h2-streams.0.tgzCLI ( npm install ./e2b-cli-2.21.1-devin-1790937196-retry-refused-h2-streams.0.tgzCode Interpreter JS SDK ( npm install ./e2b-code-interpreter-2.8.1-devin-1790937196-retry-refused-h2-streams.0.tgzDesktop JS SDK ( npm install ./e2b-desktop-2.4.1-devin-1790937196-retry-refused-h2-streams.0.tgzPython SDK ( pip install ./e2b-2.52.0+devin.1790937196.retry.refused.h2.streams-py3-none-any.whlCode Interpreter Python SDK ( pip install ./e2b_code_interpreter-2.10.1+devin.1790937196.retry.refused.h2.streams-py3-none-any.whlDesktop Python SDK ( pip install ./e2b_desktop-2.6.0+devin.1790937196.retry.refused.h2.streams-py3-none-any.whl |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 725a51c279
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…dies, fix changeset package Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline retry-amplification finding, I also checked the file-descriptor-leak candidates noted during investigation: _ReplayableContent.body()'s finally forwards .close() to the original content when it's a generator, so streamed bodies (including those backed by a real file handle wrapped in a generator) are closed the same way they were before this change — not a new leak.
Extended reasoning...
This PR adds HTTP/2 refused-stream replay logic to the Python SDK's BalancingTransport (sync and async in e2b/api/client_sync and client_async), plus an is_refused_stream helper and a bounded _ReplayableContent body mirror; it's transport/retry-layer code with no auth, crypto, or data-exposure surface touched. A confirmed inline finding shows the new per-connection retry loop composes multiplicatively with the existing outer ConnectionRetryTransport's retries (each allowing up to connection_retries), so a single logical request can now take roughly (connection_retries+1)^2 physical attempts under mixed GOAWAY/connect-failure conditions instead of connection_retries+1 — worth a human look before merging. I separately checked a ruled-out concern about file descriptor leaks in the new streaming wrapper and found closing is correctly forwarded to the original generator, consistent with prior behavior.
| while True: | ||
| connection = self.balancer.acquire(origin) | ||
| try: | ||
| response = connection.transport.execute_sync(request) | ||
| except BaseException as e: | ||
| self.balancer.release(connection, origin) | ||
| if retries >= connection_retries or not is_refused_stream(e): | ||
| raise | ||
| replay = content if replayable is None else replayable.replay() | ||
| if not isinstance(replay, bytes): | ||
| raise | ||
| retries += 1 |
There was a problem hiding this comment.
🟡 (optional) Callers of the shared transport (REST API, envd HTTP, envd RPC) can now take up to (connection_retries+1)^2 physical connection attempts for one logical request instead of connection_retries+1, under mixed GOAWAY/connect-failure conditions. BalancingTransport.execute_sync's new refused-stream retry loop (client_sync/init.py:179-190) runs inside ConnectionRetryTransport, which separately retries up to connection_retries times on ConnectionError (client_sync/init.py:288-289). If a reacquired connection fails to connect, is_refused_stream is False so it raises out of the inner loop, the outer wrapper retries, and a new inner loop gets its full budget again. Fix: share one attempt/time budget across both retry layers so the bounds compose additively, not multiplicatively. Same stacking in client_async/init.py:173-184 with its ConnectionRetryTransport at client_async/init.py:283-284.
Why this was flagged
During a rolling restart of envd/API edge nodes, requests can hit both a graceful GOAWAY (StreamError NO_ERROR/REFUSED_STREAM) and a raw connect failure (ConnectionError) across different connection attempts for the same logical call. Before this diff, BalancingTransport.execute_sync (client_sync/init.py) made exactly one attempt per call, so ConnectionRetryTransport's max_retries=connection_retries (client_sync/init.py:288-289) bounded the whole request to connection_retries+1 attempts. Now BalancingTransport.execute_sync loops up to connection_retries+1 times per call (client_sync/init.py:179-190); if one of those attempts raises ConnectionError, is_refused_stream(e) is False so it re-raises immediately, letting the outer ConnectionRetryTransport retry and re-enter a fresh inner loop with a full budget. Nothing caps the product of the two loops, so one request can open far more TCP/TLS connections and take far longer than connection_retries ever implied.
Verification: The amplification is mechanically real and reachable. Stack: ConnectionRetryTransport(BalancingTransport(build), max_retries=connection_retries) (client_sync/init.py:288-289) retries ConnectionError up to connection_retries times, wrapping BalancingTransport.execute_sync's own inner refused-stream retry loop (lines 178-192).
There was a problem hiding this comment.
Still applies after 297efcf, but now only to streamed bodies refused with REFUSED_STREAM that alternate with connect failures across attempts. bytes bodies no longer go through this loop. Leaving it for the maintainers to decide whether a shared retry budget is worth it.
There was a problem hiding this comment.
Security review of the HTTP/2 refused-stream replay change found one medium integrity issue: abandon() on non-replayed errors closes unstarted streamed bodies that the outer ConnectionRetryTransport then retries empty. Other modules reported no qualifying findings.
Sent by Cursor Security Agent: Security Reviewer
…, matching pyqwest#248 Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
…h BalancingTransport Co-Authored-By: mish@e2b.dev <mish@e2b.dev>


Summary
Since the 2.52.0 multi-connection balancing (#1825), streams no longer starve at
MAX_CONCURRENT_STREAMS=100, and a gracefulGOAWAY(NO_ERROR, "max_age")moves new work to fresh connections. One race remained: a request sent on a retiring connection after the server'sGOAWAYlast_stream_idis refused, and that error reached the caller asConnectError: Request failed.Who resends what after curioswitch/pyqwest#248:
bytesbodies (unary RPCs, REST): reqwest's default retry layer resends them, up to 2 times. Allow to connect to custom infra #248 doesn't change that.commands.run/connect, which connectrpc sends as an iterator): reqwest can't clone these bodies, so pyqwest raisesStreamError. With Allow to connect to custom infra #248 the code isREFUSED_STREAMfor a GOAWAY refusal too (before Allow to connect to custom infra #248 it wasNO_ERROR, which the SDK couldn't tell apart from anRST_STREAM(NO_ERROR)).BalancingTransport(sync + async) now replays only that second case, on a newly acquired connection, up toconnection_retriestimes:execute, before any response headers arrive. Nothing else is replayed: notNO_ERROR, other stream codes (CANCEL,INTERNAL_ERROR, …),WriteError/ReadError, or errors while reading the response body._ReplayableContentsends an unread body again from the untouched iterator. A body that was read is replayed from a copy taken as it was sent, but only if the whole body was read and was at most 64 KiB (replay_buffer_limit). It closes the original iterator (close()/aclose()), as pyqwest would.ConnectionRetryTransport(UNBUFFERED), each attempt gets a fresh wrapper around the original iterator from pyqwest's retry middleware. Soabandon()after aConnectionErrordoesn't empty the body for the outer retry; tests cover this.Verified with a local h2 GOAWAY repro on pyqwest
main(#248): every streaming request crossing the GOAWAY succeeded and 2 refused streams were replayed. Withconnection_retries=0, they fail withStreamError(REFUSED_STREAM, "stream refused by GOAWAY …"). Unit tests pass on pyqwest 0.10.0 and onmain.Blocked on a pyqwest release: #248 has merged but isn't in a release yet (0.11.0 predates it). On pyqwest 0.10 a GOAWAY refusal is still
NO_ERROR, so this PR doesn't retry it. Thepyqwest>=0.10.0,<0.11floor needs bumping to the release that includes #248, along with the code-interpreter/desktop lockfiles.Follow-up (not in this PR): the JS SDK has no equivalent replay for refused streams.
Link to Devin session: https://app.devin.ai/sessions/192c0c8057d44718ba82baaac8c95483
Open in Devin Desktop: https://app.devin.ai/desktop/session/192c0c8057d44718ba82baaac8c95483?variant=devin
Requested by: @mishushakov