fix(runner): stop paying two container-start retry budgets, degrade properly (DEV-2857) - #324
Merged
Merged
Conversation
Sentry DEMOS-1Z's "Container is starting. Please retry in a moment." error
is NOT a missing retry: @cloudflare/sandbox@0.12.3 already retries every
503 for it (BaseTransport.fetch -> fetchWithResponseRetry, shouldRetry:
r => r.status === 503, budget max(120_000, 30_000+90_000+30_000) = 150s,
~7 attempts). The SandboxError writeFiles() sees is the exhausted end of
that loop, not a first attempt, so this fix adds zero retries anywhere.
The real bug was workers/api/src/index.ts's mkdir catch ("dir may exist")
swallowing that transient AFTER the SDK had already burned its full ~140s
budget, so the next writeFile opened a fresh one and paid a second budget.
Sentry DEMOS-20 measured the sum directly: sessionElapsedMs 283943 (4m44s)
for one POST /api/session.
Three edits:
- session-lifecycle.ts: narrow isContainerStartingFailure predicate/message,
deliberately separate from isAtCapacityFailure and NOT_RUNNING_PATTERN.
- index.ts: rethrow the transient from the mkdir catch instead of swallowing
it (the only lever on the wall clock - drops worst case from ~284s to
~140s), and degrade to a 503 + warning-level Sentry capture in the create
handler's catch, alongside the existing at-capacity branch.
- packages/runtime/src/container.ts: add a container_starting tier to
sessionStartMessage so the enveloped 503 doesn't fall through to the
generic "session start failed (503): ..." wrapper, which trips App.tsx's
describeRuntimeError heuristic and would tell a visitor to install Docker.
Deploy order is load-bearing: ship the runtime/authoring app before the
Worker, or visitors in the gap get the install-Docker text for a slow boot.
Fixes DEMOS-1Z and DEMOS-20.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Sentry DEMOS-1Z and DEMOS-20. Triaged under DEV-2852.
The ticket's premise was wrong, and the evidence changed the fix
DEV-2857 was filed as "the error message literally asks for a retry and nothing retries." That is false at the layer that matters.
@cloudflare/sandbox@0.12.3— pinned inpnpm-lock.yamlsince 2026-07-08, i.e. before the first DEMOS-1Z event — already retries.sandbox.writeFileandsandbox.mkdirboth route throughBaseTransport.fetch→fetchWithResponseRetrywithshouldRetry: r => r.status === 503. The budget ismax(120_000, 30_000 + 90_000 + 30_000)= 150 000 ms, backoff 3s→30s capped, stopping when under 15s remain: roughly 7 attempts over ~135s per RPC. The stringContainer is starting. Please retry in a moment.exists only in the Durable Object'scontainerFetchcatch, as a 503 body.So the
SandboxErrorreachingwriteFilesis the exhausted end of an SDK retry loop, not a first attempt. Adding attempts 8–12 could not help a container that has already refused for 140s, and would push out a request that was already far too long.The real bug, with a production measurement
The
catch { /* dir may exist */ }aftermkdirswallowed this transient after it had already burned a full ~140s budget. The firstwriteFilebelow then opened a fresh one.The measurement comes from DEMOS-20, a companion issue the original triage missed entirely (same trace family, same first-seen minute as DEMOS-1Z's first event):
session_elapsed_bucket: >=120sextra.sessionElapsedMs:283 943 — a visitor waited 4 minutes 44 seconds before getting a 500.284s ≈ two full SDK budgets, exactly what the code predicts: one per distinct directory, plus one for the first write.
The fix: zero retries added
The house has two patterns for platform transients, and the ticket picked the wrong one.
preview-boot.tsis retry-then-terminal because we own the retry there. At-capacity / DEMOS-33 is recognise-degrade-report because the retry is not ours to do. DEMOS-1Z is the second family.session-lifecycle.ts—CONTAINER_STARTING_PATTERN = /container is starting/i,isContainerStartingFailure,CONTAINER_STARTING_CODE,containerStartingMessage. Deliberately narrow and deliberately separate: it does not matchNOT_RUNNING_PATTERN(a different, teardown-only fault),NOT_RUNNING_PATTERNis not widened, andisAtCapacityFailurestays capacity-only so a visitor never sees "we are at capacity" for a slow boot.index.tsmkdir catch — rethrow this transient."dir may exist"is the only failure that swallow was written for; a container that never started is not that. This is the only lever we have on the wall clock: the SDK budget has a hardmax(120_000, …)floor andsetRetryTimeoutMsis reachable only through the DO's private client, so we cannot make an attempt fail faster — only stop paying for two. Worst case drops from ~284s to ~140s.index.tsdegrade branch — 503 with an envelope, plusSentry.captureExceptionatlevel: "warning"under fingerprint["tier2-session-container-starting"], mirroring the existingtier2-teardown-declinedcapture. Placed in the create handler's catch rather than insidewriteFiles, so one branch coversmkdir,writeFile,startProcessandexposePort— all of which reach the container through the samecontainerFetch— and becauseclosedWhileCreating()has already run there, preserving the orphan check.packages/runtime/src/container.ts— acontainer_startingtier insessionStartMessage. Without it, a 503 with an envelope and an unknown code falls through tosession start failed (503): …, which trips thedescribeRuntimeErroralternation inApp.tsxand tells the visitor to install Docker.Why no retry, on the record: a "single immediate retry" compromise was considered and rejected — no mechanism to succeed against a container that has refused for 140s, and it costs a fresh 150s budget. If a future reviewer overrides this, the retry must sit at the call site (not inside
writeFiles), wrapwriteFilesonly and neverstartProcess(not idempotent — it would boot a second dev server under one session), re-checkisTombstonedbetween attempts, and take an injectedsleep.Ship the runtime/authoring app first (the new tier is inert until the Worker sends the code), then the Worker. Reversed, visitors in the gap get the "install Docker" message.
Verification
pnpm test—1058 tests / 1056 pass / 0 fail / 2 todo(todos pre-existing)pnpm typecheck— all 4 packages cleanpipeline/session-create-container-starting.test.mjs5/5;session-lifecycle.test.mjs23/23;session-start-failure.test.mjs23/23Revert-checks, each isolated:
session-start-failureassertionPlus a mutation check: widening the predicate to also match
/eacces/iturns T4 red, so the narrowness is genuinely pinned rather than being an artifact of the fake sandbox.T2 is the load-bearing test — it asserts
mkdircalled once andwriteFile/startProcessnever. That call-count assertion is the whole proof of the double-budget fix, and it stands in for the "retry count" assertion the ticket asked for, since the retry belongs to the SDK.Two harness facts from the plan turned out to be wrong and are documented in the new spec's header:
FRAMEWORK_DEVcarriesreact-js, not barereact(which isBUILD_CONFIG-only and would 400); and a flat file map never triggersmkdirat all, since its directory resolves toCONTAINER_ROOTwhichwriteFilesskips — so the fixture needs a nested path.Sentry follow-through
Fixes DEMOS-1Z and DEMOS-20. Expect volume to move rather than vanish: DEMOS-1Z stops,
tier2-session-container-startingstarts at roughly 7/month aswarninginstead oferror. If it drops to exactly zero, suspect the message match broke rather than that the world improved.Noted, out of scope
instance_type: standard-1,max_instances: 5. The events cluster (4 within 6 minutes on 09-02, ~35 min after a deploy), which smells like post-deploy image cold start or pool churn rather than a per-request race. Worth its own ticket.🤖 Generated with Claude Code
Note
Medium Risk
Changes Tier-2 session create failure handling and user-visible errors on a hot path; mitigated by narrow message matching, no added retries, and extensive pipeline tests. Deploy order (runtime before Worker) matters for correct UX.
Overview
Fixes DEV-2857: when sandbox
mkdirfails with the platform’s exhausted “Container is starting” error, the Worker no longer swallows it and burns a second SDK retry budget on the followingwriteFile(~4+ minute sessions).Worker:
isContainerStartingFailureand acontainer_starting503 envelope (user-facing copy + Sentry warning fingerprinttier2-session-container-starting). Themkdircatch rethrows this transient; the session create handler degrades instead of returning 500.Runtime:
sessionStartMessagepasses throughcontainer_startingunwrapped so the authoring app does not rewrite the message into “install Docker”.Tests:
@sentry/cloudflarestub in pipeline hooks; newPOST /api/sessionroute tests (503 degrade, singlemkdir, EEXIST/EACCES guards, Sentry level/fingerprint).Deploy: Ship runtime/authoring before the Worker so the new error code is understood client-side.
Reviewed by Cursor Bugbot for commit c96008e. Bugbot is set up for automated code reviews on this repo. Configure here.