Skip to content

Fix transport retry budget exceeding before container startup completes - #449

Merged
ghostwriternr merged 4 commits into
mainfrom
fix/transport-retry-budget-awareness
Mar 5, 2026
Merged

ghostwriternr merged 4 commits into
mainfrom
fix/transport-retry-budget-awareness

Conversation

@whoiskatrin

Copy link
Copy Markdown
Contributor

Summary

  • Make the transport layer's 503 retry budget aware of configured container startup timeouts, so the client doesn't give up before the container finishes starting
  • Previously hard-coded at 120s, the retry budget now automatically scales to instanceGetTimeoutMS + portReadyTimeoutMS + 30s (with a 120s floor)

Problem

The SDK has two independent timeout layers that could fight each other:

  1. Container startup timeouts (instanceGetTimeoutMS + portReadyTimeoutMS) — configurable up to 5min + 10min = 15min total
  2. Transport retry budget (BaseTransport.fetch()) — hard-coded at 120s

When 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.

Customer sets:  instanceGetTimeoutMS = 300_000 (5 min)
                portReadyTimeoutMS  = 600_000 (10 min)

Transport gives up at 120s → customer sees failure
Container would have started at ~180s → never reached

Changes

File Change
transport/types.ts Add retryTimeoutMs to TransportConfig
transport/base-transport.ts Use configurable retry budget (default 120s, backwards compatible)
clients/types.ts Add retryTimeoutMs to HttpClientOptions
clients/sandbox-client.ts Thread retryTimeoutMs to WebSocket transport
clients/base-client.ts Thread retryTimeoutMs to transport creation
sandbox.ts Compute retry budget from startup timeouts; recreate client when timeouts change

Reviewer notes

  • The retry budget formula is max(120_000, instanceGetTimeout + portReadyTimeout + 30_000). The 30s margin covers the maximum single backoff delay (capped at 30s in BaseTransport). 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), blockConcurrencyWhile when stored timeouts are loaded, and setContainerTimeouts(). The client + interpreter are recreated because retryTimeoutMs is set at transport construction time — there's no way to mutate it after the fact without adding mutable state to the transport.
  • Recreating SandboxClient is cheap (just object allocation, no I/O or connections). The WebSocket transport, if used, would need to reconnect, but setContainerTimeouts is only called once per sandbox via getSandbox().
  • Both HTTP and WebSocket transports inherit from BaseTransport, so both benefit from this fix.

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-bot

changeset-bot Bot commented Mar 3, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 285a708

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

This PR includes changesets to release 1 package
Name Type
@cloudflare/sandbox 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

@whoiskatrin

Copy link
Copy Markdown
Contributor Author

/bonk review this PR

@ask-bonk

ask-bonk Bot commented Mar 3, 2026

Copy link
Copy Markdown
Contributor

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 completes

Summary

This 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 Reviewed

7 files, +43/-4 — threading a new retryTimeoutMs option from the Sandbox DO through the client stack to the transport layer.

Correctness

The fix is correct. The core logic in sandbox.ts:328-331:

const startupBudgetMs =
  this.containerTimeouts.instanceGetTimeoutMS +
  this.containerTimeouts.portReadyTimeoutMS;
const retryTimeoutMs = Math.max(120_000, startupBudgetMs + 30_000);

The math works out:

  • Default timeouts (30s + 90s = 120s): max(120_000, 120_000 + 30_000) = 150s. This is slightly higher than the previous 120s hard-code, which is fine — the previous value was arguably too tight at defaults anyway.
  • Max timeouts (300s + 600s = 900s): max(120_000, 930_000) = 930s (15.5 min). Correctly covers the full startup window.
  • 120s floor ensures we never regress below the old behavior for edge cases.

The 30s margin matches the max single backoff delay (Math.min(3000 * 2 ** attempt, 30000) at base-transport.ts:52), which is the right value.

Threading is complete

retryTimeoutMs is threaded through all necessary layers:

  • TransportConfig (transport/types.ts:34-38)
  • BaseTransport constructor (transport/base-transport.ts:20,25)
  • HttpClientOptions (clients/types.ts:57-61)
  • BaseHttpClient → createTransport() (clients/base-client.ts:41)
  • SandboxClient → shared WebSocket transport (clients/sandbox-client.ts:52)
  • Sandbox.createSandboxClient() (sandbox.ts:337)

Both HTTP and WebSocket transports inherit from BaseTransport, so both paths are covered.

Client recreation

The client is recreated in three places (sandbox.ts:396,423,548):

  1. Constructor (initial creation)
  2. blockConcurrencyWhile (when stored timeouts are loaded from DO storage)
  3. setContainerTimeouts() (when user sets new timeouts via getSandbox())

This is the right approach. SandboxClient is a cheap allocation (no connections, no I/O). The comment in the PR description about WebSocket reconnection is accurate but correctly notes setContainerTimeouts is only called once per sandbox via getSandbox().

One minor observation

In blockConcurrencyWhile (sandbox.ts:417-424), the CodeInterpreter is also recreated alongside the client. The same happens in setContainerTimeouts (sandbox.ts:548-549). Both sites correctly recreate the interpreter, since it holds a reference to this which it uses to access this.client.interpreter. This is consistent — the CodeInterpreter constructor takes the Sandbox instance itself (line 400), not the client, so technically it would see the new client through this.client without being recreated. But recreating it is harmless and maintains the pattern from the constructor.

Backwards compatibility

  • Default behavior is preserved: when no retryTimeoutMs is provided, DEFAULT_RETRY_TIMEOUT_MS (120s) is used
  • The retryTimeoutMs field is optional in all interfaces
  • No public API changes

Changeset

The changeset message is user-facing and clearly describes the problem and fix. Correctly targets @cloudflare/sandbox only.

Verdict

This 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.

github run

github-actions[bot]

This comment was marked as outdated.

@pkg-pr-new

pkg-pr-new Bot commented Mar 3, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/sandbox-sdk/@cloudflare/sandbox@449

commit: 285a708

@github-actions

github-actions Bot commented Mar 3, 2026 •

Copy link
Copy Markdown
Contributor

🐳 Docker Images Published

Variant Image
Default cloudflare/sandbox:0.0.0-pr-449-b97fb3b
Python cloudflare/sandbox:0.0.0-pr-449-b97fb3b-python
OpenCode cloudflare/sandbox:0.0.0-pr-449-b97fb3b-opencode
Musl cloudflare/sandbox:0.0.0-pr-449-b97fb3b-musl
Desktop cloudflare/sandbox:0.0.0-pr-449-b97fb3b-desktop

Usage:

FROM cloudflare/sandbox:0.0.0-pr-449-b97fb3b

Version: 0.0.0-pr-449-b97fb3b


📦 Standalone Binary

For 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-binary

Extract from Docker:

docker run --rm cloudflare/sandbox:0.0.0-pr-449-b97fb3b cat /container-server/sandbox > sandbox && chmod +x sandbox

@ghostwriternr

Copy link
Copy Markdown
Member

/bonk this.client and this.codeInterpreter are recreated whenever container timeouts change, so retryTimeoutMs on the transport can be updated. While this works for now, it's fragile because any future component that snapshots this.client at construction time will be stuck with a stale reference unless the new code also goes via all the recreation points. Can we perhaps have a setter on the transport instead, so the retry timeout can be updated without recreating the client.

ask-bonk Bot and others added 2 commits March 5, 2026 11:38
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>
@ask-bonk

ask-bonk Bot commented Mar 5, 2026

Copy link
Copy Markdown
Contributor

Here's a summary of what was done:

Changes

The retryTimeoutMs on the transport can now be updated in place without recreating the client. This eliminates the fragility where any component that snapshots this.client at construction time would be stuck with a stale reference after timeout changes.

What changed

File Change
transport/types.ts Added setRetryTimeoutMs() to ITransport interface
transport/base-transport.ts Implemented the setter (simple field assignment)
clients/base-client.ts Added setRetryTimeoutMs() that delegates to its transport
clients/sandbox-client.ts Added setRetryTimeoutMs() that updates all sub-client transports (WS mode: one shared transport; HTTP mode: each sub-client individually)
sandbox.ts Extracted computeRetryTimeoutMs() helper; replaced client+interpreter recreation in blockConcurrencyWhile and setContainerTimeouts() with this.client.setRetryTimeoutMs()

What this eliminates

  • 2 sites that recreated this.client = this.createSandboxClient()
  • 2 sites that recreated this.codeInterpreter = new CodeInterpreter(this)

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

  • Typecheck passes clean
  • All 489 unit tests pass (2 pre-existing failures in opencode.test.ts are unrelated — they fail identically on the original branch)

github run

@github-actions github-actions 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.

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.

@ghostwriternr
ghostwriternr enabled auto-merge (squash) March 5, 2026 11:50
@ghostwriternr
ghostwriternr merged commit 909e8c5 into main Mar 5, 2026
18 checks passed
@ghostwriternr
ghostwriternr deleted the fix/transport-retry-budget-awareness branch March 5, 2026 11:54
@github-actions github-actions Bot mentioned this pull request Mar 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants