Fix transport retry budget exceeding before container startup completes - #449
Conversation
The transport layer's 503 retry budget was hard-coded at 120s, independent of the configurable container startup timeouts. Customers with large images who set longer startup timeouts still saw failures because the transport gave up before the container finished starting. The retry budget now scales to the sum of instanceGetTimeoutMS and portReadyTimeoutMS plus a 30s margin, with a 120s floor to preserve existing behavior at default settings.
🦋 Changeset detectedLatest commit: 285a708 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 |
|
/bonk review this PR |
|
The typecheck environment is incomplete (missing dependencies). This is an environment issue, not a code issue. The diff is small and straightforward enough to review by reading. Now let me review the full picture. Here's my analysis: PR Review: Fix transport retry budget exceeding before container startup completesSummaryThis is a well-scoped, clean fix for a real bug where the transport layer's hard-coded 120s retry budget could expire before the container finishes starting, even when the user has configured longer startup timeouts. Changes Reviewed7 files, +43/-4 — threading a new CorrectnessThe fix is correct. The core logic in const startupBudgetMs =
this.containerTimeouts.instanceGetTimeoutMS +
this.containerTimeouts.portReadyTimeoutMS;
const retryTimeoutMs = Math.max(120_000, startupBudgetMs + 30_000);The math works out:
The 30s margin matches the max single backoff delay ( Threading is complete
Both HTTP and WebSocket transports inherit from Client recreationThe client is recreated in three places (
This is the right approach. One minor observationIn Backwards compatibility
ChangesetThe changeset message is user-facing and clearly describes the problem and fix. Correctly targets VerdictThis PR looks good to merge. The problem is well-defined, the fix is minimal and correct, and the threading is complete across all layers. No issues found. |
commit: |
🐳 Docker Images Published
Usage: FROM cloudflare/sandbox:0.0.0-pr-449-b97fb3bVersion: 📦 Standalone BinaryFor arbitrary Dockerfiles: COPY --from=cloudflare/sandbox:0.0.0-pr-449-b97fb3b /container-server/sandbox /sandbox
ENTRYPOINT ["/sandbox"]Download via GitHub CLI: gh run download 22716151967 -n sandbox-binaryExtract from Docker: docker run --rm cloudflare/sandbox:0.0.0-pr-449-b97fb3b cat /container-server/sandbox > sandbox && chmod +x sandbox |
|
/bonk |
The transport retry budget can now be updated in place via setRetryTimeoutMs(), so timeout changes no longer require recreating the entire client and interpreter graph. This removes the risk of stale references when components snapshot the client at construction time.
Co-authored-by: ghostwriternr <ghostwriternr@users.noreply.github.com>
|
Here's a summary of what was done: ChangesThe What changed
What this eliminates
The initial construction in the constructor still creates both normally. Only the timeout-update paths changed from "throw away and rebuild" to "mutate in place." Verification
|
There was a problem hiding this comment.
OpenCode Review
Looks good to merge. This PR fixes a real coordination issue between container startup timeouts and transport retry budgets that could cause confusing failures for users with large container images.
Key strengths:
- Clean threading of the retry timeout through all transport layers
- Proper formula that accounts for both instance provisioning and port readiness timeouts
- Maintains backward compatibility with 120s floor
- Updates client retry budget when timeouts change at runtime
- Good separation between WebSocket (shared transport) and HTTP (per-client transport) modes
Architecture aligns well with the three-layer SDK design:
- Sandbox DO computes the budget from its container timeouts
- SandboxClient propagates it to all HTTP clients
- BaseTransport uses it for 503 retry logic
Formula makes sense: max(120_000, instanceGetTimeout + portReadyTimeout + 30_000) covers the full startup window plus maximum backoff delay.
The implementation correctly handles both modes (WebSocket vs HTTP) and updates retry budgets when timeouts change. This ensures customers who configure longer startup timeouts for large images won't see premature 503 failures.
Summary
instanceGetTimeoutMS + portReadyTimeoutMS + 30s(with a 120s floor)Problem
The SDK has two independent timeout layers that could fight each other:
instanceGetTimeoutMS+portReadyTimeoutMS) — configurable up to 5min + 10min = 15min totalBaseTransport.fetch()) — hard-coded at 120sWhen customers configure longer startup timeouts for large images (e.g., 6+ GB), the transport layer gives up at 120s even though the DO is still waiting for the container to start. The request fails with a 503, and the customer never benefits from the timeout they configured.
Changes
transport/types.tsretryTimeoutMstoTransportConfigtransport/base-transport.tsclients/types.tsretryTimeoutMstoHttpClientOptionsclients/sandbox-client.tsretryTimeoutMsto WebSocket transportclients/base-client.tsretryTimeoutMsto transport creationsandbox.tsReviewer notes
max(120_000, instanceGetTimeout + portReadyTimeout + 30_000). The 30s margin covers the maximum single backoff delay (capped at 30s inBaseTransport). The 120s floor preserves current behavior when timeouts are at defaults (30s + 90s = 120s → floor kicks in → 120s, same as before).createSandboxClient()is called from three places now: constructor (line 385),blockConcurrencyWhilewhen stored timeouts are loaded, andsetContainerTimeouts(). The client + interpreter are recreated becauseretryTimeoutMsis set at transport construction time — there's no way to mutate it after the fact without adding mutable state to the transport.SandboxClientis cheap (just object allocation, no I/O or connections). The WebSocket transport, if used, would need to reconnect, butsetContainerTimeoutsis only called once per sandbox viagetSandbox().BaseTransport, so both benefit from this fix.