Skip to content

fix(python-sdk): replay streamed requests whose HTTP/2 stream was refused by a GOAWAY - #1930

Draft
devin-ai-integration[bot] wants to merge 4 commits into
mainfrom
devin/1790937196-retry-refused-h2-streams
Draft

devin-ai-integration[bot] wants to merge 4 commits into
mainfrom
devin/1790937196-retry-refused-h2-streams

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Since the 2.52.0 multi-connection balancing (#1825), streams no longer starve at MAX_CONCURRENT_STREAMS=100, and a graceful GOAWAY(NO_ERROR, "max_age") moves new work to fresh connections. One race remained: a request sent on a retiring connection after the server's GOAWAY last_stream_id is refused, and that error reached the caller as ConnectError: Request failed.

Who resends what after curioswitch/pyqwest#248:

BalancingTransport (sync + async) now replays only that second case, on a newly acquired connection, up to connection_retries times:

def is_refused_stream(e):  # e2b.api
    return isinstance(e, StreamError) and e.code == REFUSED_STREAM

# BalancingTransport.execute[_sync]
replayable = None if bytes body else _ReplayableContent(content)
while True:
    connection = balancer.acquire(origin)
    try: response = connection.transport.execute(request)
    except BaseException as e:
        balancer.release(connection, origin)
        replay = None
        if replayable and retries < connection_retries and is_refused_stream(e):
            replay = replayable.replay()
        if replay is None:
            replayable.abandon(); raise   # closes an unstarted body
        retries += 1; request = request with content=replay
  • The check covers only errors raised by execute, before any response headers arrive. Nothing else is replayed: not NO_ERROR, other stream codes (CANCEL, INTERNAL_ERROR, …), WriteError/ReadError, or errors while reading the response body.
  • _ReplayableContent sends 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.
  • Inside ConnectionRetryTransport (UNBUFFERED), each attempt gets a fresh wrapper around the original iterator from pyqwest's retry middleware. So abandon() after a ConnectionError doesn'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. With connection_retries=0, they fail with StreamError(REFUSED_STREAM, "stream refused by GOAWAY …"). Unit tests pass on pyqwest 0.10.0 and on main.

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. The pyqwest>=0.10.0,<0.11 floor 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

… GOAWAY

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@changeset-bot

changeset-bot Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 81bfce2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@e2b/python-sdk Patch

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

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts retries 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.md bumps "e2b" (the JS package), but this change only touches Python. It should probably be "@e2b/python-sdk".

Written by Devin

@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Package Artifacts

Built from a67a88c. Download artifacts from this workflow run.

JS SDK (e2b@2.52.1-devin-1790937196-retry-refused-h2-streams.0):

npm install ./e2b-2.52.1-devin-1790937196-retry-refused-h2-streams.0.tgz

CLI (@e2b/cli@2.21.1-devin-1790937196-retry-refused-h2-streams.0):

npm install ./e2b-cli-2.21.1-devin-1790937196-retry-refused-h2-streams.0.tgz

Code Interpreter JS SDK (@e2b/code-interpreter@2.8.1-devin-1790937196-retry-refused-h2-streams.0):

npm install ./e2b-code-interpreter-2.8.1-devin-1790937196-retry-refused-h2-streams.0.tgz

Desktop JS SDK (@e2b/desktop@2.4.1-devin-1790937196-retry-refused-h2-streams.0):

npm install ./e2b-desktop-2.4.1-devin-1790937196-retry-refused-h2-streams.0.tgz

Python SDK (e2b==2.52.0+devin.1790937196.retry.refused.h2.streams):

pip install ./e2b-2.52.0+devin.1790937196.retry.refused.h2.streams-py3-none-any.whl

Code Interpreter Python SDK (e2b-code-interpreter==2.10.1+devin.1790937196.retry.refused.h2.streams):

pip install ./e2b_code_interpreter-2.10.1+devin.1790937196.retry.refused.h2.streams-py3-none-any.whl

Desktop Python SDK (e2b-desktop==2.6.0+devin.1790937196.retry.refused.h2.streams):

pip install ./e2b_desktop-2.6.0+devin.1790937196.retry.refused.h2.streams-py3-none-any.whl

@mishushakov
mishushakov marked this pull request as ready for review October 2, 2026 10:40
@mishushakov
mishushakov self-requested a review as a code owner October 2, 2026 10:40

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Devin Review

Comment thread packages/python-sdk/e2b/api/__init__.py Outdated
Comment thread packages/python-sdk/e2b/api/client_sync/__init__.py Outdated
Comment thread packages/python-sdk/e2b/api/client_sync/__init__.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/python-sdk/e2b/api/__init__.py Outdated
…dies, fix changeset package

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
@mishushakov
mishushakov marked this pull request as draft October 2, 2026 10:52

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +179 to +190
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Open in Web View Automation 

Sent by Cursor Security Agent: Security Reviewer

Comment thread packages/python-sdk/e2b/api/client_sync/__init__.py
devin-ai-integration Bot and others added 2 commits October 5, 2026 12:19
…, matching pyqwest#248

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
…h BalancingTransport

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
@devin-ai-integration devin-ai-integration Bot changed the title fix(python-sdk): replay requests whose HTTP/2 stream was refused by a GOAWAY fix(python-sdk): replay streamed requests whose HTTP/2 stream was refused by a GOAWAY Oct 5, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant