feat(python): retry rate-limited control-plane requests - #1862
Conversation
🦋 Changeset detectedLatest commit: ed105ec The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
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 |
Package ArtifactsBuilt from 87356c6. Download artifacts from this workflow run. JS SDK ( npm install ./e2b-2.49.1-feat-python-retry-after-rate-limits.0.tgzCLI ( npm install ./e2b-cli-2.19.1-feat-python-retry-after-rate-limits.0.tgzCode Interpreter JS SDK ( npm install ./e2b-code-interpreter-2.8.1-feat-python-retry-after-rate-limits.0.tgzDesktop JS SDK ( npm install ./e2b-desktop-2.4.1-feat-python-retry-after-rate-limits.0.tgzPython SDK ( pip install ./e2b-2.49.0+feat.python.retry.after.rate.limits-py3-none-any.whlCode Interpreter Python SDK ( pip install ./e2b_code_interpreter-2.10.0+feat.python.retry.after.rate.limits-py3-none-any.whlDesktop Python SDK ( pip install ./e2b_desktop-2.5.0+feat.python.retry.after.rate.limits-py3-none-any.whl |
There was a problem hiding this comment.
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:
- T-3a — the new
retriesparameter onConnectionConfig.__init__is a defaulted positional, not keyword-only. - T-69 — the
ApiParams.retriesdocstring omits the failure mode (InvalidArgumentExceptionon a negative/non-int value) and that0disables 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.
There was a problem hiding this comment.
💡 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".
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
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 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.
## 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>
Stack
main.main.Split from #1848; this PR contains the Python implementation and its package-specific changeset.
Summary
Retry-Afterhandling to both sync and async Python control-plane API clients.retries=0to disable them.429responses with a valid non-negative integer delta-secondsRetry-Afterheader.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
Validation
pnpm run format,pnpm run lint, andpnpm run typecheckpassed.git diff --checkpassed.From
packages/python-sdk: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.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.