feat(sdk): retry control-plane requests on 502/503 and connection failures - #1888
devin-ai-integration[bot] wants to merge 12 commits into
Conversation
Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
🦋 Changeset detectedLatest commit: 0399882 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 08c4f31. Download artifacts from this workflow run. JS SDK ( npm install ./e2b-2.51.1-devin-1789734898-retry-502-503.0.tgzCLI ( npm install ./e2b-cli-2.20.1-devin-1789734898-retry-502-503.0.tgzCode Interpreter JS SDK ( npm install ./e2b-code-interpreter-2.8.1-devin-1789734898-retry-502-503.0.tgzDesktop JS SDK ( npm install ./e2b-desktop-2.4.1-devin-1789734898-retry-502-503.0.tgzPython SDK ( pip install ./e2b-2.51.0+devin.1789734898.retry.502.503-py3-none-any.whlCode Interpreter Python SDK ( pip install ./e2b_code_interpreter-2.10.0+devin.1789734898.retry.502.503-py3-none-any.whlDesktop Python SDK ( pip install ./e2b_desktop-2.6.0+devin.1789734898.retry.502.503-py3-none-any.whl |
There was a problem hiding this comment.
TASTE.md review of the changed code only (retry layer in both SDKs, ConnectionOpts/ApiParams docs, changesets, tests).
Checked: parity (T-1/T-2), option shape (T-3/T-3a, T-10), acronym casing (T-12), named constants (T-47), timeout semantics (T-45), config precedence (T-49), entry points (T-54), error messaging (T-62/T-64), docs (T-69–T-71).
2 minor violations, 1 non-blocking note. No public API surface changes; the retries option and its docs mirror across SDKs, the retry-timeout message is actionable, and the base/max backoff defaults live in named constants in both languages. The two inline comments are the same T-47 nit on the JS and Python side — the jitter range is the only retry tunable spelled as a literal at the call site. The T-3a note on random_ is on an internal transport (not exported from e2b/__init__.py), so it's advisory.
Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
|
for network errors I think in some test we already specify the shape of network error message, could you check if we're missing anything - in particular the sdk has to work cross-environments like Node, Deno, Bun, CF Workers and the Browser |
… classifier Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
Checked against the shapes in
The strings the existing tests pin ( The gap is CF Workers and browsers: there a connect failure is indistinguishable from a mid-request drop (
Which do you prefer? Happy to do 2 (or 2 restricted to GET/HEAD/DELETE) if you want retries there. |
|
Decision (from Mish): keep it strict — connection retries only for the unambiguous connect-phase shapes (Node/Bun/Deno); opaque CF Workers / browser network errors are rethrown as before. No further code change; the test in aba3ca7 pins this. |
There was a problem hiding this comment.
I reviewed this PR and the automated bug hunt found no new bugs (one candidate — retrying non-idempotent POSTs like Sandbox.create on a 502/503 — was flagged as a duplicate of an earlier self-review comment, not independently confirmed safe). Because this changes core control-plane retry semantics, and safety for 502/503 retries rests on an assumption about backend/gateway behavior (that these statuses are always returned before the request is processed) that isn't verifiable from this diff alone, a human look would still be worthwhile — especially since packages/js-sdk/tests/client.test.ts explicitly exercises replaying POST /sandboxes after a 503.
What was reviewed: JS/Python backoff parity (100ms/0.1s base, 10s cap, 0.5-1.0 jitter, same RETRYABLE_STATUSES), the isConnectionError cause-chain/AggregateError walk and its exclusion of post-write errors (ECONNRESET), the request-timeout deadline math, and matching changesets/docstring updates for both SDKs.
Extended reasoning...
Overview
The diff extends control-plane HTTP retry logic in both the JS and Python SDKs to cover 502/503 responses (honoring Retry-After when present, otherwise exponential backoff with jitter, base 100ms/0.1s capped at 10s) and, in JS only, low-level connection-establishment failures via a new isConnectionError helper that walks cause chains and AggregateError members. withRateLimitRetry was renamed to withRetry; Python's RetryableTransport/AsyncRetryableTransport gained a keyword-only random_ dependency and a shared _retry_delay helper. Docs (connectionConfig.ts, connection_config.py), tests (retry.test.ts, client.test.ts, test_rate_limit_retry_transport.py), and two changesets accompany the change. I confirmed JS and Python semantics match (same retryable statuses, backoff base/cap, jitter range) and both sync and async Python transports were updated in parallel, consistent with CLAUDE.md's cross-SDK parity requirement. Changesets exist for both e2b and @ e2b/python-sdk, satisfying the public-surface-change requirement (retry conditions changed, withRateLimitRetry renamed, new keyword-only constructor args).
Security risks
No injection, auth-bypass, or data-exposure risk. The main correctness risk is retry safety: the change now retries requests on 502/503 regardless of HTTP method, which includes non-idempotent operations like Sandbox.create (POST) and pause/fork/snapshot. packages/js-sdk/tests/client.test.ts has a test literally named "client retries sandbox creation after a 503," confirming this is intentional, not an oversight. The PR description argues 502/503 are "rejected before/without processing" by the backend/gateway (unlike 504, which is deliberately excluded because the request may have already been applied), so replaying is safe. That argument is plausible but rests on backend/gateway behavior outside this diff (and outside code this PR touches), so it can't be independently verified from the SDK code alone.
Level of scrutiny
This warrants a higher-than-default level of scrutiny: it changes default retry behavior for every control-plane call across both SDKs (opt-out via retries: 0, not opt-in), and duplicate-sandbox-creation-on-retry is a plausible real-world failure mode if the "502/503 always precede processing" assumption doesn't hold universally for the actual API gateway/placement service. This exact question was already raised inline on the PR itself (retry.ts:9) and resolved only by the PR author, which per review guidance does not count as an independently addressed objection.
Other factors
Test coverage is otherwise solid: backoff sequencing with injected randomness, cap behavior, Retry-After preference, exhaustion, deadline checks, and non-retried statuses (400/404/500/504) are all covered on the JS side; sync/async Python equivalents exist for 502/503. isConnectionError correctly excludes post-write failures (ECONNRESET) and its own tests cover Node/undici, Bun, Deno, and AggregateError cases. Given the design question isn't a coding bug but a domain-knowledge judgment call about backend semantics, I chose to defer rather than flag it as a concrete bug.
This review covers commit 7d27596, which is no longer the latest commit on this pull request; later commits are not covered by it.
|
okay I think we should retry only GET methods for now |
Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
Done in 24c7020 —
Same |
Replace the GET-only rule with an operation denylist: post-write network errors are retried for every control-plane operation except those that create a resource without an idempotency key. Python's RetryableTransport now retries ReadError/WriteError/RemoteProtocolError the same way. Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
…idempotent extension Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
|
Policy is now operation-based and sourced from the spec (9428234 + d75d5c5):
The marked operations ( Blocked: the runtime side is a patch (extension + spec test + regenerated |
A POST the spec does not annotate is now emitted into the generated non-idempotent list, so a forgotten annotation upstream costs a retry rather than risking a duplicate resource. Explicit x-idempotent: false on any method is still honored; GET/PUT/PATCH/DELETE default to replayable. Adds unit tests for the generator. Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
…potent: true Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
Drops the x-idempotent spec generator (the spec extension is not landing for now) and returns to the inline list in retry.ts / retry.py; POST /v3/templates is replayable (keyed by template name, a replay supersedes the unstarted build). Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
Summary
The control-plane retry layer (
withRetryin JS,RetryableTransport/AsyncRetryableTransportin Python) so far only retried429responses that carryRetry-After. The API answers capacity/busy conditions with503(placement_errors.go, pause/fork/snapshot "node is busy, please retry") and gateway/backend failures with502, none of which sendRetry-After, so the SDKs surfaced them immediately. Follow-up to the policy in EN-2619, which called for 502/503 with jittered backoff.Retry decision per response (both SDKs):
Everything else is unchanged: same
retriesoption (default 3 → 4 attempts total,0disables), retries stop when the wait would pass the request-timeout deadline (60 s bound when timeouts are disabled), intermediate responses are closed, streaming bodies still get a single attempt, envd/volume-content requests are not retried. Exhausted retries return the final response, soServiceBusyError(503) and thestatusCode-carryingSandboxError(502) are raised as before.504is deliberately not retried: a backend timeout may have already applied the request (e.g. created a sandbox), whereas 502/503 are rejected before/without processing.Network errors
Two classes, because only one of them is safe for every operation:
isReplayable(request)is false for the operations that mint a resource without a client-supplied idempotency key (POST /sandboxes,/v2/sandboxes,/sandboxes/{id}/fork,/sandboxes/{id}/snapshots,/api-keys,/admin/teams/{id}/api-keys,/volumes,/secrets,/events/webhooks), so a replay of a request the server may already have processed cannot create a duplicate.POST /v3/templatesis replayable: the request is keyed by template name, so a replay resolves to the same template and supersedes the not-yet-started build (verified upstream with a replay test). Every other operation is idempotent or a replay fails with a 404/409 the SDK already tolerates (pause → 409, kill → 404). DELETEs are replayable: a second delete is a no-op server-side; the 404 it returns is the existingkill()→falsecontract, left as is.The list lives as
NON_REPLAYABLE_OPERATIONS([method, anchored RegExp]) inpackages/js-sdk/src/retry.tsandpackages/python-sdk/e2b/retry.py; a new resource-creating POST has to be added to both.JS: Python already retries connection-establishment failures via
ConnectionRetryTransport(pyqwest,E2B_CONNECTION_RETRIES, default 3). JS had nothing, sowithRetrynow retries whenfetchthrows — sameretries, backoff and deadline as 502/503:isConnectionErrorwalkscausechains /AggregateErrormembers and matches only errors raised before request bytes are written: Node/undiciECONNREFUSED,ENOTFOUND,EAI_AGAIN,EHOSTUNREACH,ENETUNREACH,ENETDOWN,EHOSTDOWN,UND_ERR_CONNECT_TIMEOUT, any error withsyscallconnect/getaddrinfo, Bun'sConnectionRefused, Deno'sclient error (Connect). Post-write failures (ECONNRESET,terminated,Network connection lost.) and the opaqueTypeError: Failed to fetchbrowsers and Workers raise for every network failure are indistinguishable from "request already reached the server" and go through theisReplayablegate.Python:
RetryableTransport/AsyncRetryableTransportadditionally retryhttpx.ReadError/WriteError/RemoteProtocolError(raised once the request was at least partially written) for replayable operations;ConnectError/ConnectTimeoutstay withConnectionRetryTransportunderneath so connect failures aren't double-retried.Other changes:
withRateLimitRetry→withRetry; the discarded body'scancel()is no longer awaited — msw's interceptor never settles it, which hung the new client-level test. Against real serverscancel()resolves immediately either way.random_injectable on both transports (keyword-only, likesleep/monotonic) for deterministic tests; the retry-timeout message no longer says "rate-limited".retriesinConnectionOpts/ApiParams, changesets fore2band@e2b/python-sdk.Usage
Nothing to opt into — a
Sandbox.create()hitting a transient503 not enough capacity(or anECONNREFUSED/ENOTFOUNDon the way to the API) now succeeds on a later attempt; asandbox.kill()whose connection drops mid-flight is replayed, aSandbox.create()in the same situation is not:Tests
packages/js-sdk/tests/retry.test.ts: backoff sequence with injectedrandom, cap at 10 s,Retry-Afterpreferred for 503, exhaustion, deadline check, 400/404/500/504 untouched;isConnectionError/isRetryableFetchErroragainst fetch error shapes observed in Node, Bun, Deno, Cloudflare Workers and browsers;isReplayablefor every listed operation plus replayable neighbours (GET …/fork,…/fork/extra, extra path segment); connect failures retried forPOST /sandboxes, post-write errors retried forDELETE /sandboxes/{id}but rethrown forPOST /sandboxes; aborts rethrown;retries: 0passthrough.tests/client.test.ts:E2Bclient retriesPOST /sandboxesafter a 503.packages/python-sdk/tests/test_rate_limit_retry_transport.py: sync + async equivalents (502/503, read/write/protocol errors gated byis_replayable,ConnectErrorpassed through, list coverage).Run:
pnpm exec vitest run tests/retry.test.ts tests/client.test.ts(js-sdk),uv run pytest tests/test_rate_limit_retry_transport.py(python-sdk).Link to Devin session: https://app.devin.ai/sessions/70b160f61f8e48bda8558173b88d9cd3
Open in Devin Desktop: https://app.devin.ai/desktop/session/70b160f61f8e48bda8558173b88d9cd3?variant=devin
Requested by: @mishushakov