1+ // Vitest runs under node, but the src/ tsconfig only has browser types — hence the suppressions.
2+ // @ts -expect-error node builtin without @types/node
3+ import { readFileSync } from 'node:fs'
4+ // @ts -expect-error node builtin without @types/node
5+ import { createRequire } from 'node:module'
16import { describe , expect , it , vi } from 'vitest'
7+ import { deserialize , serialize } from 'capnweb'
8+ import { AUTH_ERROR_CODES , createAuthError } from '@gadgets/workshop-shared/api'
29
310vi . mock ( './errorReporting' , ( ) => ( { reportIssue : vi . fn ( ) } ) )
411
512import { reportIssue } from './errorReporting'
613import {
714 classifyRpcError , getDurableObjectId , isDurableObjectResetError , isOverloadedError ,
8- isTransientRpcError , logRpcFailure , reportDoResetError , withDoResetRetry ,
15+ CONNECTION_MESSAGES , isTransientRpcError , logRpcFailure , reportDoResetError , withDoResetRetry ,
916} from './rpcErrors'
1017
1118// The reject frame observed in prod for a DO storage-timeout reset.
@@ -31,6 +38,8 @@ describe('classifyRpcError', () => {
3138 'Durable Object reset because its code was updated.' ,
3239 "Durable Object's isolate exceeded its memory limit and was reset." ,
3340 'Durable Object exceeded its CPU time limit and was reset.' ,
41+ // What later calls on an already-dead capability reject with (flagless).
42+ 'The execution context which hosts this callback is no longer running.' ,
3443 ] ) {
3544 expect ( classifyRpcError ( new Error ( message ) ) ) . toBe ( 'do-reset' )
3645 }
@@ -50,9 +59,15 @@ describe('classifyRpcError', () => {
5059 expect ( classifyRpcError ( new Error ( 'WebSocket connection failed.' ) ) ) . toBe ( 'connection' )
5160 expect ( classifyRpcError ( new Error ( 'RPC session was shut down by disposing the main stub' ) ) )
5261 . toBe ( 'connection' )
62+ expect ( classifyRpcError ( new Error ( 'Attempted to use RPC stub after it has been disposed.' ) ) )
63+ . toBe ( 'connection' )
5364 } )
5465
5566 it ( 'classifies auth failures, which must never be retried or quieted' , ( ) => {
67+ // Coded errors are authoritative; bare messages are the fallback for older deployments.
68+ expect ( classifyRpcError ( createAuthError ( AUTH_ERROR_CODES . invalidSessionToken ) ) ) . toBe ( 'auth' )
69+ expect ( classifyRpcError ( Object . assign ( new Error ( 'nope' ) , { code : 'INVALID_SESSION_TOKEN' } ) ) )
70+ . toBe ( 'auth' )
5671 expect ( classifyRpcError ( new Error ( 'invalid session token' ) ) ) . toBe ( 'auth' )
5772 expect ( classifyRpcError ( new Error ( 'Not authenticated with Access.' ) ) ) . toBe ( 'auth' )
5873 } )
@@ -118,6 +133,29 @@ describe('withDoResetRetry', () => {
118133 expect ( fn ) . toHaveBeenCalledTimes ( 1 )
119134 } )
120135
136+ it ( 'retries once on a retryable-flagged invocation failure' , async ( ) => {
137+ vi . useFakeTimers ( )
138+ try {
139+ const fn = vi . fn ( )
140+ . mockRejectedValueOnce ( Object . assign ( new Error ( 'internal error' ) , { remote : true , retryable : true } ) )
141+ . mockResolvedValueOnce ( 'ok' )
142+ const result = withDoResetRetry ( fn )
143+ await vi . advanceTimersByTimeAsync ( 2000 )
144+ expect ( await result ) . toBe ( 'ok' )
145+ expect ( fn ) . toHaveBeenCalledTimes ( 2 )
146+ } finally {
147+ vi . useRealTimers ( )
148+ }
149+ } )
150+
151+ // Local transport errors carry no flags; their recovery belongs to the connection manager,
152+ // so the retry must refuse them even though they classify as transient.
153+ it ( 'does not retry flagless transport errors' , async ( ) => {
154+ const fn = vi . fn ( ) . mockRejectedValue ( new Error ( 'Peer closed WebSocket' ) )
155+ await expect ( withDoResetRetry ( fn ) ) . rejects . toThrow ( 'Peer closed WebSocket' )
156+ expect ( fn ) . toHaveBeenCalledTimes ( 1 )
157+ } )
158+
121159 it ( 'gives up after the second failure' , async ( ) => {
122160 vi . useFakeTimers ( )
123161 try {
@@ -150,3 +188,32 @@ describe('logRpcFailure', () => {
150188 }
151189 } )
152190} )
191+
192+ // Canary: these client-local errors carry no flags, so the classifier matches capnweb's message
193+ // strings. Pin them to the installed build so an upgrade fails here, not silently in the UX.
194+ describe ( 'capnweb transport messages' , ( ) => {
195+ it ( 'still exist in the installed capnweb build' , ( ) => {
196+ const require = createRequire ( import . meta. url )
197+ const source = readFileSync ( require . resolve ( 'capnweb' ) , 'utf8' )
198+ for ( const message of CONNECTION_MESSAGES ) {
199+ expect ( source , `capnweb no longer raises "${ message } "` ) . toContain ( message )
200+ }
201+ } )
202+ } )
203+
204+ // Canary: the classifier's primary path reads flags/codes off the deserialized error, so pin
205+ // capnweb's custom-property round-trip too (serialize/deserialize use the same wire frame as
206+ // RPC rejections). A regression here would silently demote every classification to the message
207+ // fallback and drop auth codes entirely.
208+ describe ( 'capnweb error serialization' , ( ) => {
209+ it ( 'round-trips the custom properties the classifier reads' , ( ) => {
210+ const sent = Object . assign ( storageTimeoutReset ( ) , { code : AUTH_ERROR_CODES . invalidSessionToken } )
211+ const received = deserialize ( serialize ( sent ) ) as Error & Record < string , unknown >
212+ expect ( received ) . toBeInstanceOf ( Error )
213+ expect ( received . durableObjectReset ) . toBe ( true )
214+ expect ( received . overloaded ) . toBe ( true )
215+ expect ( received . durableObjectId ) . toBe ( 'eed0859e' )
216+ expect ( received . code ) . toBe ( AUTH_ERROR_CODES . invalidSessionToken )
217+ expect ( classifyRpcError ( received ) ) . toBe ( 'do-reset' )
218+ } )
219+ } )
0 commit comments