Skip to content

Classify Durable Object platform failures as retryable protocol errors - #1788

Merged
RhysSullivan merged 3 commits into
mainfrom
fix/do-platform-error-classification
Aug 27, 2026
Merged

Classify Durable Object platform failures as retryable protocol errors#1788
RhysSullivan merged 3 commits into
mainfrom
fix/do-platform-error-classification

Conversation

@RhysSullivan

@RhysSullivan RhysSullivan commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Problem

A request that landed on a session Durable Object the platform had just reset, destroyed, or was redeploying came back as an unhandled 500 with no body, instead of a protocol error the client could act on.

Three things caused it:

  • agent-handler.ts guards two of its three session-stub touchpoints — _cf_scheduleDestroy is wrapped in Effect.ignore, target.fetch has a catch that maps a destroyed session to a 404 reconnect — but the validateMcpSessionOwner RPC between them was bare. A session whose object had already been torn down rejected that call, and nothing caught it.
  • Nothing anywhere classified Cloudflare platform Durable Object failures. The codebase classifies transient-vs-definitive failures carefully for the auth provider, but a deploy-time object reset, a storage timeout, and a storage backend blip were all treated as application defects.
  • markActivity is pure bookkeeping — it writes a timestamp and arms the idle alarm — yet it ran as the last, fatal statement of init(). A platform reset of that one write tore down a session that was already fully built and serving, and failed the user's request.

The same failure was also reported twice internally: the Durable Object captured it through its own error seam, and the error-reporting SDK's Durable Object instrumentation captured the identical rejection again as it escaped the method.

Fix

One classifyDurableObjectError module in packages/hosts/cloudflare recognizes the platform's reset vocabulary (destroyed, Durable Object reset because its code was updated, storage timeout, storage internal error, a cancelled blockConcurrencyWhile, internal error; reference = …, and the runtime's own retryable flag) and returns either "this id is dead, reconnect" or "transient, retry". Anything it does not recognize returns null and keeps its current behaviour — rethrown and reported — so a real defect is never silently swallowed.

It is used at three places:

  • The validateMcpSessionOwner RPC is now guarded like its siblings. A recognized platform failure renders the existing envelopes: 404 for a dead id, or a 503 carrying Retry-After for a transient reset.
  • The target.fetch catch, which previously only understood the literal destroyed-session abort, now covers the rest of the vocabulary too.
  • Init-time storage bookkeeping (markActivity) is best-effort against recognized platform resets only. The session survives, and the idle alarm self-heals on the next request. A bookkeeping write that fails for any other reason is still a defect and still fails.

Error reporting gets a single owner: the Durable Object claims a cause once it has decided what to do with it, and beforeSend drops the auto-instrumentation's echo of that same cause. Anything the object never claims — an alarm crash, a transport fault — is untouched and still reported. Each classified failure records mcp.do.reset_kind on its span and in a structured log, so the volume stays countable per cause now that it is no longer a pile of 500s.

Testing

Primary verification is the new e2e scenario e2e/cloud/mcp-destroyed-session-envelope.test.ts, which reproduces the production symptom black-box.

It terminates a session while requests are already in flight — a client with an open tool loop, rather than one that politely drains first — so the isolate abort at the end of the teardown lands on a request the handler is holding. It then asserts that every answer is a well-formed JSON-RPC envelope, never a bare 500, and that any retryable verdict carries Retry-After.

With the product code reverted to its pre-fix state and this scenario in place, it fails on exactly that assertion, receiving a raw HTML 500 error page whose embedded message is destroyed, thrown at the unguarded validateMcpSessionOwner call:

AssertionError: no request on a terminating session produces an unhandled server error
+295ms 500 <!DOCTYPE html> <html lang="en"> … <title>Error</title>

The race is forced rather than hoped for. Each teardown's request stream now stops on the observed post-wipe verdict rather than on a fixed timer, which removes enough dead load to afford 12 crossings at 24 concurrent requests each inside a smaller request budget than before — and the scenario checks per teardown that every crossing was actually straddled, so a run that never entered the window fails instead of passing vacuously. Pre-fix it fails 6 of 6 runs, leaking 3–9 unhandled 500s each; with the fix it passes 6 of 6. It is still tuned to sit under the request volume at which the shared org-membership lookup on the dev stack starts answering 403 — an unrelated pre-existing behaviour that otherwise drowns the signal — and it uses one pre-minted identity per teardown for the same reason.

Two properties the teardown alone cannot demonstrate are asserted alongside it, because both would otherwise regress silently while every other assertion still passed:

  • A session the client itself terminated is never advertised as retryable. Widening the classifier so a dead id renders as a 503 "restarting, retry" is worse than the 500 this PR removes — the client is told to keep asking a question that can never succeed.
  • A foreign bearer and a missing credential stay definitive: 403 with the session-ownership code, 401, and no Retry-After on either.

Unit tests are kept only for seams this scenario genuinely cannot observe, and the case it now covers end to end (a dead session id rendering as a 404 reconnect envelope) was removed from them:

  • The remaining platform resets — a deploy replacing the script, a storage timeout, a backend blip, a cancelled blockConcurrencyWhile — cannot be provoked on the dev stack at all, so the classifier and the wire response it renders for them are pinned in packages/hosts/cloudflare/src/mcp/durable-object-errors.test.ts.
  • Init surviving a reset of its bookkeeping write, and still failing on an unrecognized one, needs the reset injected at that write: agent-session-durable-object.test.ts.
  • One capture per defect is a property of the error payload, not of any HTTP response: apps/cloud/src/observability/observability.test.ts.

cloud/mcp-protocol.test.ts still passes; typecheck, lint, format:check clean.

Excluded, and left for follow-ups: restructuring the long blockConcurrencyWhile init, overriding _cf_scheduleDestroy, and the cold-init org-meta work.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 27, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
executor-cloud 040aa82 Aug 27 2026, 10:35 PM

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Cloudflare preview

Torn down — the PR is closed.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 27, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
executor-marketing 040aa82 Commit Preview URL

Branch Preview URL
Aug 27 2026, 10:33 PM

@pkg-pr-new

pkg-pr-new Bot commented Aug 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

@executor-js/cli

npm i https://pkg.pr.new/@executor-js/cli@1788

@executor-js/config

npm i https://pkg.pr.new/@executor-js/config@1788

@executor-js/execution

npm i https://pkg.pr.new/@executor-js/execution@1788

@executor-js/sdk

npm i https://pkg.pr.new/@executor-js/sdk@1788

@executor-js/codemode-core

npm i https://pkg.pr.new/@executor-js/codemode-core@1788

@executor-js/runtime-quickjs

npm i https://pkg.pr.new/@executor-js/runtime-quickjs@1788

@executor-js/plugin-file-secrets

npm i https://pkg.pr.new/@executor-js/plugin-file-secrets@1788

@executor-js/plugin-graphql

npm i https://pkg.pr.new/@executor-js/plugin-graphql@1788

@executor-js/plugin-keychain

npm i https://pkg.pr.new/@executor-js/plugin-keychain@1788

@executor-js/plugin-mcp

npm i https://pkg.pr.new/@executor-js/plugin-mcp@1788

@executor-js/plugin-onepassword

npm i https://pkg.pr.new/@executor-js/plugin-onepassword@1788

@executor-js/plugin-openapi

npm i https://pkg.pr.new/@executor-js/plugin-openapi@1788

executor

npm i https://pkg.pr.new/executor@1788

commit: 040aa82

Terminate a session while requests are already in flight instead of after
they drain, so the isolate abort at the end of the teardown lands on a
request the handler is holding. That reaches the unhandled 500 without the
fix and passes with it.

Also narrow the blockConcurrencyWhile match to the runtime's own
cancellation message, so a defect thrown from inside the callback is not
read as a platform reset.
… run

Stop each teardown stream on the observed post-wipe verdict instead of a fixed
timer, which cuts enough wasted load to raise the crossing count and in-flight
concurrency. Assert every teardown was straddled, and that a terminated session
is never advertised as retryable.
@RhysSullivan
RhysSullivan marked this pull request as ready for review August 27, 2026 23:32
@RhysSullivan
RhysSullivan merged commit e3acef7 into main Aug 27, 2026
44 checks passed
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.

1 participant