Classify RPC errors and recover quietly from Durable Object resets - #58
Classify RPC errors and recover quietly from Durable Object resets#58ndisidore wants to merge 6 commits into
Conversation
This comment was marked as outdated.
This comment was marked as outdated.
19d7150 to
6af1085
Compare
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
6af1085 to
3e00b45
Compare
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
| @validateRpc() | ||
| class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { | ||
| constructor(private ctx: ExecutionContext, private env: Env, | ||
| private user: DurableObjectStub<UserDurableObject>, |
There was a problem hiding this comment.
This is likely a controversial decision.
Its driven by https://developers.cloudflare.com/durable-objects/best-practices/error-handling/ specifically the block
Many exceptions leave the DurableObjectStub in a "broken" state, such that all attempts to send additional requests will just fail immediately with the original exception. To avoid this, you should avoid reusing a DurableObjectStub after it throws an exception. You should instead create a new one for any subsequent requests.
When the the user DO resets e.g. storage timeout, overloaded abort (which is exactly what we saw in the logs) that stub is permanently poisoned. Even if we retry it will fail.
This is not super obvious because the premise does hold for the workspace path: Overseer stubs get re-resolved through the namespace on each open, so a retried openGadget genuinely reaches the restarted object. The UserDO path is the only one where a stub is cached across calls.
This should be cheap and safe: namespace.get(id) is not a network call. Stub creation is local and lazy.
Why not just force a re-load? a reload doesn't avoid the retry; it is the retry, multiplied by everything else and makes blast radius wildly disproportionate. It give a worse UX as well as the entire page resets (as opposed to trying to recover silently where possible)
There was a problem hiding this comment.
i don't think it should be a problem, but do different stubs mean we lose request ordering to the userDO? i couldn't find any instances were that would be a big problem though, so it seems like a worthwhile tradeoff.
There was a problem hiding this comment.
haha when writing this I told claude "have some pre-canned responses ready for the inevitable push back"
and it had one for e-order! the concern is valid. but practically nothing in our code relies on cross-call ordering through the user stub
preserving ordering while recovering poisoned stubs would require caching and centrally invalidating the stub after native RPC failures across every UserDO operation, adding substantial kernel complexity
| // through the closure-captured dead stub could never succeed anyway. Deliberately retries even | ||
| // when `overloaded` is set alongside the reset — the reset destroyed the queue that was | ||
| // overloaded, and one jittered attempt is not a retry loop. Never use for writes. | ||
| export async function withDoResetRetry<T>(fn: () => Promise<T>, delayMs = 1500): Promise<T> { |
There was a problem hiding this comment.
This comment goes more in depth here, but I want get ahead of any guttural reactions: Retry is confined to idempotent reads, bounded to exactly 1 attempt.
3e00b45 to
4c70048
Compare
This comment was marked as outdated.
This comment was marked as outdated.
4c70048 to
86f5d6a
Compare
This comment was marked as outdated.
This comment was marked as outdated.
86f5d6a to
68397bc
Compare
This comment was marked as outdated.
This comment was marked as outdated.
Reads the structured flags enhanced_error_serialization already delivers to the browser (durableObjectReset, retryable, overloaded, durableObjectId; semantics per workerd jsg/util.c++ — see MR 238), with message matching as fallback. Nothing consumed these before: every call site treated a transient DO reset like a terminal error.
A reset DO rejects in-flight RPCs while the WebSocket stays healthy, and nothing ever re-fetched — the sidebar, model list, onboarding check, vendor branding, and connected-account subscriptions stayed broken until reload. The object restarts on its next request, so withDoResetRetry retries once after a jittered delay. Reads only; writes are never retried (a reset after commit would double-apply).
Transient failures (DO reset, connection loss) were logged as scary console errors with dead-end toasts at every load site. Route them through logRpcFailure — debug-level for transient, loud otherwise — and skip the toasts, since a reconnect or retry is expected to cure them. Failed chat sends now show an inline composer hint instead of a toast; the wording is hedged because a reset after commit means the message may have landed. DO resets on the send path report through reportDoResetError for telemetry.
Per the DO error-handling docs, flags are the supported contract: - auth strings move to a shared AUTH_ERROR_MESSAGES constant thrown by the backend and imported by the classifier, so they cannot drift - a canary test pins the flagless capnweb transport messages to the installed build, so an upgrade fails in CI rather than in the UX - the workerd reset strings are documented as re-wrap fallback only - withDoResetRetry documents why one jittered retry is safe despite the overloaded flag accompanying reset errors
68397bc to
7d776df
Compare
There was a problem hiding this comment.
Review — Classify RPC errors and recover quietly from DO resets
I reviewed PR #58 independently from a clean install, isolating this PR's own 4 commits (f7a4a14..7d776df) from the #56 stack it's built on, and re-ran every check myself rather than relying on the prior ask-bonk passes.
Verification I ran (fresh pnpm install)
vitest run src/rpcErrors.test.ts→ 20/20 pass (both canaries);src/ResourcePicker.test.tsx+src/homePromptFlow.test.tsx→ 3/3 pass.types:checkonworkshop-backend+workshop-shared+workshop-frontend→ all green.lint:check→ exit 0; the only warnings (server.ts:487/517no-shadow) predate this PR.- The load-bearing capnweb assumption, at the source: round-tripped an
ErrorcarryingdurableObjectReset/overloaded/durableObjectId/code/retryablethrough the installed capnweb'sserialize/deserialize— all five survive and the result staysinstanceof Error. So "flags/codes authoritative, messages fallback" genuinely holds over the wire, and the round-trip canary (rpcErrors.test.ts:208) pins it so a dependency upgrade fails CI instead of silently demoting every classification to message-matching.
The design holds up
- Flags-first classifier, precedence correct.
do-reset(rpcErrors.ts:59) precedesconnection(:61) andauth(:66), so a reset frame also carryingoverloaded/retryablestill resolves todo-reset(pinned atrpcErrors.test.ts:48). - Backend
get user()is the reachability fix, and safe. Re-resolving the stub per call (server.ts:93) is what lets a retried read reach the restarted incarnation; a session-cached stub stays poisoned per the DO error-handling docs.this.usersis constructor-assigned (server.ts:83) anduserIdis a constructor param — the getter is never invoked during construction, so this is not a field-init regression..id.name/.id.toString()are synchronous native-DurableObjectStubreads, so a fresh stub per access is harmless micro-churn, not a disposal leak. - Integration test reproduces the real failure.
open-gadget-rpc.test.ts:145usesabortAllDurableObjects()(non-graceful) on the same socket/sameAuthenticatedApiImpl— exactly what a cached stub could never recover from; the comment correctly rules out gracefulevictDurableObject. - Retries confined to idempotent reads/subscribes.
withDoResetRetryrefuses flagless local transport errors (rpcErrors.ts:101), so it never fires through a closure-captured dead stub — the connection manager keeps that recovery. Exactly one jittered attempt, never a loop. I confirmed the workspace-create path (routes/index.tsx:121) is a write and is correctly NOT wrapped in retry — it only classifies/quiets/rethrows. - Writes correctly not auto-retried. Chat-send keeps the inline
sendHiccuphint;submitMessagecapturessubmittedChatKeyand only sets the hint if the composer is still on that chat (ChatInterface.tsx:2467). Sound double-send reasoning (a reset landing after the write commits). - Auth drift closed. Both
authenticate-path throws migrated tocreateAuthError; the non-Base64 token path (user.ts:302) now classifies as auth instead of leaking the decoder'sSyntaxError. I grepped — the only remaining raw auth-ish strings (server.ts:715/718/741/744) are deployment-config messages surfaced verbatim from explicitlogin/createAccountsubmissions, correctly outside the session-token classification path. All sharedAUTH_ERROR_*exports are doc-commented andisAuthErrorCodemirrors the existingisOpenGadgetErrorCodeguard — kernel bar met. - "Loud on purpose" carve-outs (
Connections.tsx:73,ObserverConfigModal.tsx:154) correctly keepconsole.error+ toast where there's no retry path. - A latent leak fixed in passing:
ResourcePicker.tsxadds acancelledguard so a subscription resolving after unmount is disposed rather than stored unconditionally — and it's pinned by the newResourcePicker.test.tsx. Good.
Non-blocking observations (all previously raised, none worth changing)
ObserverConfigModal.tsx:166wrapsPromise.all([...])in the retry, so a reset on the second call re-runs both idempotent reads — harmless.providers.tsx:149wrapslistModels/getQuickModelindividually but leavesgetAiConfigunwrapped in the samePromise.all— that one read just won't retry; slightly arbitrary.chatKey={selectedChatId}staysnullacross two different not-yet-created "new chat" conversations, so a hiccup hint could persist between them — cosmetic, andhandleSendclears it on the next send.- Chat-send is the one transient surface that still fires
reportDoResetErrorto the external Reporter on every reset, whereas the retried reads don't. Defensible asymmetry; worth a one-line note so a future reader doesn't "fix" it in either direction.
Verdict
Solid, well-scoped, mergeable. The kernel change is minimal and correct, the retries are confined to idempotent reads with a fresh-stub-per-attempt getter that makes them reachable, the load-bearing wire assumption is verified and pinned, and the commit split keeps workshop-backend/workshop-shared reviewable apart from the UI. LGTM.
|
Review posted to PR #58 at 23:14:12Z. SummaryI independently reviewed PR #58 (RPC error classification + quiet DO-reset recovery) from a fresh What I verified:
Verdict: Solid, well-scoped, mergeable — LGTM. I raised four non-blocking observations (all previously surfaced), none worth changing. |
Kieran-Hulsman
left a comment
There was a problem hiding this comment.
i'm a bit out of my depth here, but i spent an hour thinking through this with an agent, and everything seems sensible to me!
| {draftUpdateBanner} | ||
| {sendHiccup && ( | ||
| <div className="px-4 pt-2 text-xs text-kumo-warning"> | ||
| Connection hiccup — your message may not have been sent. Check the thread, then try again. |
There was a problem hiding this comment.
@ndisidore what happens in this case?
- The user tries to send a message to chat
- The workspace reads the user’s profile and model configuration through its cached User DO connection.
- The User DO has reset, so that connection fails.
- The message is not written to the Overseer DO.
it looks like the message's body & attachments aren't thrown away, but retrying won't get a fresh User DO stub yet. so when the user tries to resend, will it work?
…tion E-order is guaranteed per stub, so the per-access getter from the previous commit traded ordering for reset recovery. Cache one stub per session instead, and intercept rejections through a Proxy in the getter: flagged errors (durableObjectReset/retryable) drop the cache and rethrow — the call may have executed, so the frontend owns that recovery — while a flagless dead-capability rejection proves the call never reached the DO and is re-issued exactly once on a fresh stub, safe even for writes. Two JSRPC subtleties the tests caught: `.apply` on a stub method proxy is an RPC path segment (use Reflect.apply), and JsRpcPromise.then rejects a non-function first argument. The retry goes through the raw target (via a symbol escape hatch) so cascading resets cannot retry unboundedly. The reset-recovery integration test doubles as the canary for workerd's dead-capability message; a new case pins that the cache re-arms after recovery instead of churning or staying poisoned.
Architectural: the idempotent-read retry moves from 19 per-call-site withDoResetRetry wraps (which had already drifted — providers.tsx left getAiConfig unwrapped while OnboardingWizard wrapped it) to a single withReadRetries proxy installed where useAuth creates the stub, keyed by a method-level allowlist — idempotency is a property of the method, not the call site. Future reads get the policy for free; writes and unlisted methods pass through untouched. Mechanical: the backend Proxy's RAW_USER_STUB symbol escape hatch is replaced by caching the raw stub in a second field — no symbol, no double casts, and the thrash guard compares against the trap's own target. logRpcFailure now owns do-reset telemetry via a reportSite option, collapsing the classify-report-log ritual at three action sites. The workerd dead-capability message now lives once, in workshop-shared, referenced by the backend matcher and the frontend classifier, so the frontend canary guards both. Block/line: the two coded-error families in api.ts share one codedErrorFamily factory (membership derived from the message record instead of hand-enumerated); the composer's send-hiccup hint is one state value scoped by its render condition instead of a boolean, a ref mirrored during render, and a comparison at set time; fake-timer try/finally scaffolding in rpcErrors.test.ts becomes afterEach, twin retry tests merge into it.each, and error fixtures share an rpcError helper. New tests pin the chokepoint: listed reads retry once, writes never do.
There was a problem hiding this comment.
Review — Classify RPC errors and recover quietly from DO resets
Heads-up for anyone reading the prior ask-bonk LGTMs: the design changed materially after them. Two new commits — b2e88a9 (Restore e-order by caching the user-DO stub, with reset-safe invalidation) and 7998195 (Simplification pass) — reversed the per-call get user() getter that every earlier review signed off on, and reworked the frontend retry from 19 per-call-site wraps into a single chokepoint. So I re-reviewed the current HEAD (7998195) from scratch rather than trusting the earlier passes, which no longer describe the code.
Verification I ran (fresh pnpm install)
vitest run rpcErrors.test.ts + ResourcePicker.test.tsx + homePromptFlow.test.tsx→ 25/25 pass.- Integration:
open-gadget-rpc.test.tsvia the integration config → 2 reset-recovery tests pass (4 network-dependent auth cases skipped). types:checkonworkshop-backend+workshop-shared+workshop-frontend→ all green.lint:check→ exit 0 (only pre-existing warnings;server.ts:545/575no-shadowpredate this PR).
The new kernel design — backend stub caching with reset-safe invalidation — holds up
This is the substantive change and it's a genuine improvement over both prior approaches (session-cached-forever and per-call getter). The #wrapUserStub Proxy (server.ts:119) keeps e-order by caching one stub, and splits recovery by what the rejection proves:
- Flagged (
durableObjectReset/retryable) → the call may have executed, so it drops the cache and rethrows; the frontend owns recovery. Correct — never silently re-runs a possibly-committed write. - Flagless dead-capability (
#DEAD_CAPABILITY_MESSAGES) → proves the call never reached the DO, so it re-issues once on a fresh stub, safe even for writes.
I traced the two hard cases:
- Concurrent failures on the same dead target: the thrash guard
if (this.rawUserStub === target)(:138) lets only the first failure swap the cache; a second concurrentneverSentfailure skips the swap but still retries on the currentrawUserStub— so both land on the same fresh stub, neither clobbers it. There-arms … instead of churningintegration test (:164) pins exactly this. - Cascading reset (fresh stub also dead): the retry goes through
Reflect.applyon the raw target (:144), not the proxy, so a second reset can't re-enter the wrapper — bounded to one retry. The comment at:140earns its place.
Two JSRPC subtleties are handled correctly and documented: Reflect.apply instead of value.apply (.apply is an RPC path segment on a JSRPC method proxy, :125), and .then(v=>v, onRej) because JsRpcPromise.then rejects a non-function first arg (:129). Both were caught by tests, per the commit message — good.
Pipelining is preserved where it matters. The .then(v=>v) demotes the returned JsRpcPromise to a plain Promise, which loses server-internal pipelining on the return value — but every stub-returning this.user.* method (subscribeConnectedAccounts, startResourceConfigurator, …) is just returned straight back over Cap'n Web, so nothing internal pipelines through it. .id reads take the non-function branch (:123) and aren't wrapped. Confirmed no correctness loss.
The frontend withReadRetries chokepoint is the right call
Moving retry from 19 call sites to one method-level allowlist installed at stub creation (useAuth.ts:59/85) makes idempotency a property of the method, not the call site — and it demonstrably fixes real drift (providers.tsx had left getAiConfig unwrapped while OnboardingWizard wrapped it). I checked the allowlist (rpcErrors.ts:126): all 8 entries exist on AuthenticatedApi and all are genuine reads; no write leaked in. subscribeConnectedAccounts is safe in the set — every call site .then(stub => …)s it (never pipelines through it), so demoting its promise is harmless, and a reset destroys the incarnation holding the subscription so a retry can't leak a dangling one.
The Proxy's passthrough branch (return (...args) => target[prop](...args), :149) preserves pipelining for the un-listed stub-returning methods (openGadget/newGadget/startResourceConfigurator), which several call sites rely on (SidebarWorkspaces.tsx:143 "pipelining"). Symbol.dispose is a symbol, so RETRYING_READS.has() is false and dispose passes through cleanly. Good.
Also correct
- Classifier flag-first,
do-reset→connection→authprecedence; the round-trip canary (rpcErrors.test.ts:217) pins custom-prop survival through capnweb's realserialize/deserialize, so a dependency regression fails CI instead of silently demoting to message-matching. WORKERD_DEAD_CAPABILITY_MESSAGEnow lives once inworkshop-shared, referenced by both the backend never-sent matcher and the frontend classifier — one source, one canary.codedErrorFamilyfactory derives membership from the message record instead of hand-enumerated guards; kernel bar met (all shared exports doc-commented).- Writes stay inline-hint-not-auto-retry with correct double-send reasoning;
workspace.createis correctly not retried.
Non-blocking observations
- The production dead-capability string isn't exercised end-to-end. The integration test triggers
abortAllDurableObjects(), whose message is a separate literal in#DEAD_CAPABILITY_MESSAGES; the productionWORKERD_DEAD_CAPABILITY_MESSAGEis only string-pinned in the frontend canary against our own classifier, not against workerd. So if workerd changes that production message, the never-sent retry silently stops firing there and no test catches it (the abort path still passes). Inherent to the harness — worth a one-line note near the constant so a future reader knows the real string rides on the doc-comment's "workerd probe," not a test. routes/index.tsx:69now toasts a retry-exhausteddo-reseton the model list (only suppressesconnection), while sibling reads justlogRpcFailure. Defensible (load-bearing surface) and the inline comment explains it — just an intentional asymmetry.- The already-noted cosmetic items (the
nullnew-chat send-hint key coalescing) still stand, none worth changing.
Verdict
Solid, well-scoped, mergeable — and notably better than the version the earlier reviews saw: the stub-caching-with-reset-safe-invalidation recovers e-order without giving up reset recovery, and the frontend chokepoint kills a whole class of per-call-site drift. The kernel diff is small, the subtle bits are tested and commented, and the commit split keeps workshop-backend/workshop-shared reviewable apart from the UI. LGTM.
|
Review posted to PR #58. SummaryI reviewed PR #58 and posted a fresh review — importantly, the design changed materially after all the prior The key finding: two new commits (
What I verified independently:
Verdict: LGTM — and genuinely better than the version prior reviews saw. Raised 3 non-blocking observations (the production dead-capability string isn't exercised end-to-end; an intentional toast asymmetry on the home model list; a cosmetic send-hint edge). |
Stacked on #56.
Addresses this class of error


Routine DO resets (storage timeouts, overload aborts i.e. the stuff we saw in the logs) were surfacing in the frontend as scary terminal errors. The reject frames actually arrive with structured flags (
durableObjectReset,overloaded, etc., thanks toenhanced_error_serialization), but nothing ever read them: every call site was justcatch(console.error).Two details made it worse than it needed to be:
What changed
withReadRetriesproxy installed whereuseAuthcreates the API stub, keyed by a method-level allowlist — idempotency is a property of the method, not the call site, so every consumer (present and future) gets the policy without per-call-site wrapping.durableObjectReset/retryable) drop the cached stub and propagate — the call may have executed, so the frontend owns recovery — while a flagless dead-capability rejection proves the call never reached the object and is transparently re-issued exactly once on a fresh stub, which is safe even for writes. Net effect: an idle reset costs the user nothing at all.logRpcFailurereports do-resets on action paths (chat send/new, workspace create) to the client-errors endpoint so they stay visible in telemetry. State fallbacks unchanged.codedErrorFamilyhelper). Canary tests pin the capnweb message strings we match against and round-trip the flags through capnweb's real serializer; the workerd dead-capability message lives once inworkshop-shared, referenced by both the backend matcher and the frontend classifier, so the canary guards both.Intentionally Deferred
There remains a gap in this work around "Established subscriptions remain dead after a User DO reset." - subscriptions present a slightly more challenging workflow so that work is intentionally left out here.
Testing
abortAllDurableObjects(), the non-graceful teardown): the same session recovers transparently on the first call after a reset (this also canaries the dead-capability message — if workerd's string drifts, the never-sent retry stops firing and the test fails loudly), and the stub cache re-arms instead of churning or staying poisoned.debugAbort()injector): a single idle reset followed by page navigation, and a 20-abort storm at 150ms intervals across five SPA navigations, both completed with every page functional and zero application console output. Server-side spans confirmed exactly the injected aborts as exceptions, the only casualties being long-lived subscriptions (the deferred gap above); every user-facing read and write in the window succeeded, including writes issued after resets. One in-flight write during a burst failed loudly and succeeded on manual retry — the intended contract.