Skip to content

feat(python): retry rate-limited control-plane requests - #1862

Merged
nalekseev-e2b merged 5 commits into
mainfrom
feat/python-retry-after-rate-limits
Sep 10, 2026
Merged

feat(python): retry rate-limited control-plane requests#1862
nalekseev-e2b merged 5 commits into
mainfrom
feat/python-retry-after-rate-limits

Conversation

@nalekseev-e2b

Copy link
Copy Markdown
Contributor

Stack

  1. This PR: Python SDK, based on main.
  2. JavaScript SDK: feat(js): retry rate-limited control-plane requests #1848, stacked on this branch. Merge Python first, then retarget the JS PR to main.

Split from #1848; this PR contains the Python implementation and its package-specific changeset.

Summary

  • Add configurable Retry-After handling to both sync and async Python control-plane API clients.
  • Default to 3 retries; set retries=0 to disable them.
  • Retry only 429 responses with a valid non-negative integer delta-seconds Retry-After header.
  • Respect the original timeout budget, with a 60-second aggregate retry-wait bound when request timeouts are disabled.
  • Close intermediate responses and reuse immutable buffered body bytes; streaming bodies are sent once without buffering for replay.
  • Preserve existing rate-limit errors when retries are disabled, invalid, or exhausted.

Scope

Retries apply to E2B control-plane REST calls, including volume management. Direct sandbox/envd traffic (filesystem and commands) and volume-content traffic are outside this retry layer. Existing connect-only transport retries remain unchanged.

Usage

from e2b import Sandbox, AsyncSandbox

# Sync: control-plane calls retry up to three times by default.
sandbox = Sandbox.create()
sandboxes = Sandbox.list(retries=0).next_items()

# Async equivalent (inside an async function).
sandbox = await AsyncSandbox.create()
sandboxes = await AsyncSandbox.list(retries=0).next_items()

Validation

  • Repository-wide pnpm run format, pnpm run lint, and pnpm run typecheck passed.
  • 131 focused Python tests passed on this Python-only branch.
  • git diff --check passed.
  • Full production/staging integration validation runs in CI.

From packages/python-sdk:

uv run pytest tests/test_rate_limit_retry_transport.py tests/test_api_client_transport.py tests/test_connection_config.py tests/test_volume_client.py
Python sync/async memory verification

Run from packages/python-sdk. No network requests or API credentials are needed. The 32 MiB payload is allocated before tracing; three distinct requests must reference the exact same bytes object. The assertion bounds additional traced Python allocations, not native transport buffers or whole-process RSS.

uv run python <<'PY'
import asyncio
import gc
import tracemalloc

import httpx
from e2b.retry import RetryableTransport, AsyncRetryableTransport

payload = b"x" * (32 * 1024 * 1024)

async def check(async_mode):
    requests = []

    def handle(request):
        requests.append(request)
        assert request.content is payload
        return httpx.Response(
            429 if len(requests) < 3 else 200,
            headers={"Retry-After": "0"},
        )

    request = httpx.Request("POST", "https://api.test", content=payload)
    inner = httpx.MockTransport(handle)
    gc.collect()
    tracemalloc.start()
    try:
        if async_mode:
            response = await AsyncRetryableTransport(inner, retries=2).handle_async_request(request)
        else:
            response = RetryableTransport(inner, retries=2).handle_request(request)
        _, peak = tracemalloc.get_traced_memory()
    finally:
        tracemalloc.stop()
    assert response.status_code == 200 and len(requests) == 3
    assert len({id(r) for r in requests}) == 3
    assert all(r.content is payload for r in requests)
    assert peak < 4 * 1024 * 1024, peak
    print(f"{'async' if async_mode else 'sync'}: {len(requests)} attempts share the original bytes; peak extra allocation {peak / 1024:.1f} KiB")

async def main():
    await check(False)
    await check(True)

asyncio.run(main())
PY

Expected: both paths share the original payload across three attempts, with peak additional allocations below 4 MiB. Previously measured locally: about 14.5 KiB sync and 13.0 KiB async.

@changeset-bot

changeset-bot Bot commented Sep 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ed105ec

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

This PR includes changesets to release 2 packages
Name Type
@e2b/python-sdk Patch
e2b 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

@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Package Artifacts

Built from 87356c6. Download artifacts from this workflow run.

JS SDK (e2b@2.49.1-feat-python-retry-after-rate-limits.0):

npm install ./e2b-2.49.1-feat-python-retry-after-rate-limits.0.tgz

CLI (@e2b/cli@2.19.1-feat-python-retry-after-rate-limits.0):

npm install ./e2b-cli-2.19.1-feat-python-retry-after-rate-limits.0.tgz

Code Interpreter JS SDK (@e2b/code-interpreter@2.8.1-feat-python-retry-after-rate-limits.0):

npm install ./e2b-code-interpreter-2.8.1-feat-python-retry-after-rate-limits.0.tgz

Desktop JS SDK (@e2b/desktop@2.4.1-feat-python-retry-after-rate-limits.0):

npm install ./e2b-desktop-2.4.1-feat-python-retry-after-rate-limits.0.tgz

Python SDK (e2b==2.49.0+feat.python.retry.after.rate.limits):

pip install ./e2b-2.49.0+feat.python.retry.after.rate.limits-py3-none-any.whl

Code Interpreter Python SDK (e2b-code-interpreter==2.10.0+feat.python.retry.after.rate.limits):

pip install ./e2b_code_interpreter-2.10.0+feat.python.retry.after.rate.limits-py3-none-any.whl

Desktop Python SDK (e2b-desktop==2.5.0+feat.python.retry.after.rate.limits):

pip install ./e2b_desktop-2.5.0+feat.python.retry.after.rate.limits-py3-none-any.whl

@devin-ai-integration devin-ai-integration 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.

TASTE.md review of the Python retry layer (e2b/retry.py, connection_config.py, client_sync/client_async wiring, changeset).

Checked: parity (T-1, T-2, T-10 — retries mirrors the JS name and default in #1848, sync/async transports are 1:1), API shape (T-3/T-3a, T-16), timeouts/config (T-45, T-47, T-49, T-51), errors (T-57, T-59, T-62–T-64), and docstrings (T-69, T-71).

2 violations, both on connection_config.py:

  1. T-3a — the new retries parameter on ConnectionConfig.__init__ is a defaulted positional, not keyword-only.
  2. T-69 — the ApiParams.retries docstring omits the failure mode (InvalidArgumentException on a negative/non-int value) and that 0 disables retries.

Not tied to a line: retry.py raises a bare httpx.TimeoutException when the retry wait would exhaust the budget. That matches how other control-plane calls currently surface httpx timeouts and the message names the knobs (T-64), so it's not flagged — but if the SDK ever maps control-plane timeouts to e2b.exceptions.TimeoutException this is one more site to update. Everything else (named constants for DEFAULT_RETRIES/MAX_RETRY_WAIT_WITHOUT_TIMEOUT_SECONDS, InvalidArgumentException for argument validation, close() reserved for transport teardown, no generated types leaking) complies.

Comment thread packages/python-sdk/e2b/connection_config.py
Comment thread packages/python-sdk/e2b/connection_config.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: 073da00684

ℹ️ 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/client_sync/__init__.py
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@nalekseev-e2b
nalekseev-e2b enabled auto-merge (squash) September 10, 2026 13:41

@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 event-hook/trace-ID finding, I checked two other candidate issues and ruled them out: an uncapped caller-supplied retries value can't be leveraged into unbounded waiting/memory use, since parse_retry_after caps the delay and each retry iteration re-checks the request deadline before sleeping (retry.py:88-108); and passing a negative retries directly to RetryableTransport/AsyncRetryableTransport (bypassing resolve_max_retries) just yields an empty range(), degrading safely to a single non-retried request rather than misbehaving.

Extended reasoning...

Given the already-confirmed inline finding that retries happen below the event-hooks boundary (causing intermediate 429 responses, including their X-E2B-Trace-ID, to be invisible to logging hooks), a human should still look at this PR, so I am not approving. I independently re-read packages/python-sdk/e2b/retry.py to check two additional candidate issues surfaced in this run's investigation: (1) resolve_max_retries has no upper bound on caller-supplied retries, and (2) the transports trust retries without re-validating it. For (1), tracing through _request_deadline/parse_retry_after/the loop's deadline check shows that even a very large retries count cannot cause unbounded waiting, since any Retry-After value that would push past the request's deadline (or the 60s default bound when no timeout is set) short-circuits the loop and returns the response immediately — so this is not exploitable as a resource-exhaustion vector. For (2), a negative retries passed directly to the transport class (skipping the validating factory) simply produces an empty range(self.retries + 1)... actually range(0) for retries=-1, which returns after the first handle_request call with no retry attempted — safe degradation, not a bug. Both are worth naming since they were part of this run's investigation and are not restatements of the confirmed inline finding.

Comment thread packages/python-sdk/e2b/retry.py
nalekseev-e2b and others added 3 commits September 10, 2026 16:22
## Stack

1. **Python SDK: #1862**, based on `main`.
2. **This PR: JavaScript SDK**, based on
`feat/python-retry-after-rate-limits`.

Merge #1862 first, then retarget this PR to `main`. This PR's diff
contains only the JS implementation, tests, and JS changeset.

## Summary

- add configurable `Retry-After` handling to the JavaScript SDK
control-plane client
- default to 3 retries; set `retries: 0` to disable them
- retry only `429` responses carrying a valid non-negative delta-seconds
`Retry-After` header
- preserve existing rate-limit errors when retries are disabled,
invalid, timed out, or exhausted
- mirror the Python transport's replayability gate: streaming bodies
(detected with `isReadableStreamLike`) are sent exactly once, everything
else is replayed with `Request.clone()` — safe because openapi-fetch
serializes every control-plane body to a string before constructing the
Request, and all streaming uploads (envd filesystem, volume content,
template files) use separate, unwrapped clients
- close intermediate responses and keep retries within the original
timeout budget
- leave envd, sandbox filesystem/command, and volume-content clients
outside the rate-limit retry layer

## Scope

The retry layer is installed only on E2B control-plane REST clients.
Direct sandbox/envd traffic, including filesystem and command
operations, is not retried. Volume-content traffic is also not retried;
volume management calls made through the E2B control-plane API follow
the control-plane policy. Existing connect-only transport retries remain
unchanged.

## Usage

### JavaScript

```ts
// Control-plane calls retry up to 3 times by default.
const sandbox = await Sandbox.create()

// Disable rate-limit retries for this call.
const sandboxes = await Sandbox.list({ retries: 0 }).nextItems()
```

## Validation

- `pnpm run format`
- `pnpm run lint`
- `pnpm run typecheck`
- focused JavaScript tests passed (`tests/retry.test.ts`,
`tests/client.test.ts`, `tests/connectionConfig.test.ts`,
`tests/volume/file.test.ts`, `tests/sandbox/configPropagation.test.ts`)
- 26 retry tests passed under both Node and Cloudflare workerd
- `git diff --check`

`pnpm run test` was also attempted. The recursive run reached live
integration suites but could not complete in this local environment
because credentials and external sandbox fixtures were unavailable.

---------

Co-authored-by: Mish Ushakov <10400064+mishushakov@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@nalekseev-e2b
nalekseev-e2b merged commit 9136603 into main Sep 10, 2026
75 of 77 checks passed
@nalekseev-e2b
nalekseev-e2b deleted the feat/python-retry-after-rate-limits branch September 10, 2026 15:18
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.

2 participants