Skip to content

Commit 7d776df

Browse files
committed
Harden rpcErrors against message drift
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
1 parent a29aba0 commit 7d776df

5 files changed

Lines changed: 149 additions & 18 deletions

File tree

packages/workshop-backend/src/server.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { RpcStub, RpcTarget, newWorkersRpcResponse } from "capnweb";
22
import { validateRpc } from "capnweb-validate";
33
import type { JWTPayload } from "jose";
4-
import { PublicApi, AuthenticatedApi, Overseer, GadgetMetadataWithTimestamps, AiChatAuthorInfo, AiModelConfig, AiGatewayInfo, AiModelProvider, ConnectedAccountsSubscriber, ConnectedAccountsFilter, GatekeeperVendorFilter, ObserverConfigCallback, BlueprintLibrarySummary, BlueprintPublicInfo, BlueprintUserSummary, BlueprintBindingAssignment, AgentSpawnerConfig, WorkpieceId, BLUEPRINT_SCREENSHOT_PATH_PREFIX, BLUEPRINT_SCREENSHOT_R2_PREFIX, blueprintScreenshotUrl, ServerConfig, CloudflareUsageInfo, CloudflareAccountOption, LoginAttempt, GatekeeperAppInfo, AdminApi, GatekeeperVendorInfo, OutputFormatOffer, ListOutputsResult, createOpenGadgetError, getOpenGadgetErrorCode, OPEN_GADGET_ERROR_CODES } from '@gadgets/workshop-shared/api';
4+
import { PublicApi, AuthenticatedApi, Overseer, GadgetMetadataWithTimestamps, AiChatAuthorInfo, AiModelConfig, AiGatewayInfo, AiModelProvider, ConnectedAccountsSubscriber, ConnectedAccountsFilter, GatekeeperVendorFilter, ObserverConfigCallback, BlueprintLibrarySummary, BlueprintPublicInfo, BlueprintUserSummary, BlueprintBindingAssignment, AgentSpawnerConfig, WorkpieceId, BLUEPRINT_SCREENSHOT_PATH_PREFIX, BLUEPRINT_SCREENSHOT_R2_PREFIX, blueprintScreenshotUrl, ServerConfig, CloudflareUsageInfo, CloudflareAccountOption, LoginAttempt, GatekeeperAppInfo, AdminApi, GatekeeperVendorInfo, OutputFormatOffer, ListOutputsResult, createOpenGadgetError, getOpenGadgetErrorCode, OPEN_GADGET_ERROR_CODES, AUTH_ERROR_CODES, createAuthError } from '@gadgets/workshop-shared/api';
55
import type { UiFeatureFlags } from "@gadgets/workshop-shared/feature-flags";
66
import { getServerConfig } from "./deployment-config.js";
77
import { isPasswordAuthEnabled, getAuthGatekeeperAllowlist } from "./auth/config.js";
@@ -671,7 +671,7 @@ class PublicApiImpl extends RpcTarget implements PublicApi {
671671
async authenticate(token: string): Promise<AuthenticatedApi> {
672672
let split = token.split(':');
673673
if (split.length !== 2) {
674-
throw new Error("Invalid session token.");
674+
throw createAuthError(AUTH_ERROR_CODES.invalidSessionToken);
675675
}
676676

677677
let userId = this.users.idFromName(split[0]);
@@ -687,7 +687,7 @@ class PublicApiImpl extends RpcTarget implements PublicApi {
687687

688688
async authenticateFromCfAccess(): Promise<AuthenticatedApi> {
689689
if (!this.accessPayload) {
690-
throw new Error("Not authenticated with Access.");
690+
throw createAuthError(AUTH_ERROR_CODES.notAuthenticatedWithAccess);
691691
}
692692

693693
let email = this.accessPayload.email as string;

packages/workshop-backend/src/user.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { RpcStub } from "capnweb";
2-
import { GadgetMetadataWithTimestamps, AiChatAuthorInfo, AiModelConfig, SUGGESTED_MODELS, CollaboratorRole, ConnectedAccountsSubscriber, ConnectedAccountsFilter, GatekeeperVendorFilter, GadgetMetadata, BlueprintMetadata, BlueprintLibrarySummary, BlueprintSource, BlueprintUserSummary, BLUEPRINT_SCREENSHOT_R2_PREFIX, GatekeeperVendorInfo, BlueprintOutput, OutputSummary, WorkpieceId, ListOutputsResult } from '@gadgets/workshop-shared/api';
2+
import { GadgetMetadataWithTimestamps, AiChatAuthorInfo, AiModelConfig, SUGGESTED_MODELS, CollaboratorRole, ConnectedAccountsSubscriber, ConnectedAccountsFilter, GatekeeperVendorFilter, GadgetMetadata, BlueprintMetadata, BlueprintLibrarySummary, BlueprintSource, BlueprintUserSummary, BLUEPRINT_SCREENSHOT_R2_PREFIX, GatekeeperVendorInfo, BlueprintOutput, OutputSummary, WorkpieceId, ListOutputsResult, AUTH_ERROR_CODES, createAuthError } from '@gadgets/workshop-shared/api';
33
import { Gatekeeper, GatekeeperUser, GatekeeperUserVerifier, GatekeeperVendor, AccountDescription, VendorDescription, GatekeeperConnectCallback, SupportedResource, ResourceConfiguratorFrame, AppUiContext, GatekeeperUiFrame } from "@gadgets/workshop-shared/gatekeeper";
44
import { shouldAutoProvisionAccount, ambientGatekeeperMode } from "./provisioning-policy.js";
55
import { CloudflareGatekeeperUser } from "@gadgets/workshop-shared/cloudflare-gatekeeper";
@@ -296,12 +296,19 @@ export class UserDurableObject extends DurableObject<Cloudflare.Env> {
296296
}
297297

298298
async authenticate(token: string): Promise<void> {
299-
let tokenBytes = Uint8Array.fromBase64(token);
299+
let tokenBytes: Uint8Array;
300+
try {
301+
tokenBytes = Uint8Array.fromBase64(token);
302+
} catch {
303+
// A corrupt (non-Base64) token must classify as an auth failure like any other bad token,
304+
// not surface as the decoder's SyntaxError.
305+
throw createAuthError(AUTH_ERROR_CODES.invalidSessionToken);
306+
}
300307
let hash = await crypto.subtle.digest('SHA-256', tokenBytes);
301308
let tokenId = new Uint8Array(hash).toHex();
302309
let session = this.storage.sessions.get(tokenId);
303310
if (!session) {
304-
throw new Error("invalid session token");
311+
throw createAuthError(AUTH_ERROR_CODES.invalidSessionToken);
305312
}
306313
}
307314

packages/workshop-frontend/src/rpcErrors.test.ts

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
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'
16
import { describe, expect, it, vi } from 'vitest'
7+
import { deserialize, serialize } from 'capnweb'
8+
import { AUTH_ERROR_CODES, createAuthError } from '@gadgets/workshop-shared/api'
29

3-
vi.mock('./errorReporting', () => ({ reportIssue: vi.fn() }))
10+
vi.mock('./errorReporting', () => ({ reportIssue: vi.fn<(site: string, err: unknown, options?: object) => void>() }))
411

512
import { reportIssue } from './errorReporting'
613
import {
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
})
@@ -102,7 +117,7 @@ describe('withDoResetRetry', () => {
102117
it('retries once after a reset error', async () => {
103118
vi.useFakeTimers()
104119
try {
105-
const fn = vi.fn().mockRejectedValueOnce(storageTimeoutReset()).mockResolvedValueOnce('ok')
120+
const fn = vi.fn<() => Promise<string>>().mockRejectedValueOnce(storageTimeoutReset()).mockResolvedValueOnce('ok')
106121
const result = withDoResetRetry(fn)
107122
await vi.advanceTimersByTimeAsync(2000)
108123
expect(await result).toBe('ok')
@@ -113,15 +128,38 @@ describe('withDoResetRetry', () => {
113128
})
114129

115130
it('does not retry non-reset errors', async () => {
116-
const fn = vi.fn().mockRejectedValue(new Error('Workspace not found.'))
131+
const fn = vi.fn<() => Promise<string>>().mockRejectedValue(new Error('Workspace not found.'))
117132
await expect(withDoResetRetry(fn)).rejects.toThrow('Workspace not found.')
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<() => Promise<string>>()
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<() => Promise<string>>().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 {
124-
const fn = vi.fn().mockRejectedValue(storageTimeoutReset())
162+
const fn = vi.fn<() => Promise<string>>().mockRejectedValue(storageTimeoutReset())
125163
const result = withDoResetRetry(fn)
126164
result.catch(() => {})
127165
await vi.advanceTimersByTimeAsync(2000)
@@ -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+
})

packages/workshop-frontend/src/rpcErrors.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AUTH_ERROR_MESSAGES, getAuthErrorCode } from '@gadgets/workshop-shared/api'
12
import { reportIssue } from './errorReporting'
23

34
// Classifies errors surfaced through capnweb RPC. The backend runs with
@@ -8,21 +9,33 @@ import { reportIssue } from './errorReporting'
89

910
export type RpcErrorClass = 'do-reset' | 'connection' | 'auth' | 'other'
1011

12+
// Fallbacks: workerd errors normally arrive with `durableObjectReset` set (capnweb carries
13+
// the flags in a dedicated slot); the first four strings only matter when something re-wrapped
14+
// the error. The last is what LATER calls on an already-dead capability reject with — flagless
15+
// (verified in a workerd probe); the flagged error only reaches calls in flight at reset time.
16+
// Over our RPC surface a dead hosting context always means the capability needs reopening.
1117
const DO_RESET_MESSAGES = [
1218
'Durable Object reset because its code was updated',
1319
'Durable Object storage operation exceeded timeout',
1420
"Durable Object's isolate exceeded its memory limit",
1521
'Durable Object exceeded its CPU time limit',
22+
'The execution context which hosts this callback is no longer running',
1623
]
1724

18-
// Transport failures raised locally by capnweb, plus its own-session teardown message.
19-
const CONNECTION_MESSAGES = [
25+
// Transport failures raised locally by capnweb, plus its own-session teardown message. These
26+
// carry no flags, so matching messages is all we have; a canary test pins them to the installed
27+
// capnweb build so an upgrade fails loudly here instead of silently in the UX.
28+
export const CONNECTION_MESSAGES = [
2029
'Peer closed WebSocket',
2130
'WebSocket connection failed.',
2231
'RPC session was shut down by disposing the main stub',
32+
// What RPCs on an already-disposed stub reject with — e.g. the zombie the connection manager
33+
// disposes while an outage is being recovered.
34+
'Attempted to use RPC stub after it has been disposed',
2335
]
2436

25-
const AUTH_MESSAGES = ['invalid session token', 'Not authenticated with Access']
37+
// Fallback for auth errors thrown without a code (older deployments); codes are authoritative.
38+
const AUTH_MESSAGES = Object.values(AUTH_ERROR_MESSAGES)
2639

2740
const messageOf = (err: unknown) => (err instanceof Error ? err.message : String(err))
2841

@@ -48,7 +61,11 @@ export function classifyRpcError(err: unknown): RpcErrorClass {
4861
if (flag(err, 'retryable') || CONNECTION_MESSAGES.some(m => message.includes(m))) {
4962
return 'connection'
5063
}
51-
if (AUTH_MESSAGES.some(m => message.includes(m))) return 'auth'
64+
// 'auth' is deliberately terminal — never quieted, never retried, and there is no missing
65+
// re-auth handler: the session is invalid and only a fresh login cures it.
66+
if (getAuthErrorCode(err) !== undefined || AUTH_MESSAGES.some(m => message.includes(m))) {
67+
return 'auth'
68+
}
5269
return 'other'
5370
}
5471

@@ -69,13 +86,19 @@ export function logRpcFailure(message: string, err: unknown): boolean {
6986

7087
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
7188

72-
// Retries an idempotent call once after a DO reset: the object restarts on its next request,
73-
// so a single delayed attempt usually succeeds. Never use for writes.
89+
// Retries an idempotent call once after a backend-side transient failure: a DO reset (the object
90+
// restarts on its next request, reached via a fresh stub) or a `retryable`-flagged invocation
91+
// failure. Flags only survive on errors that round-tripped from the backend, so a flagged error
92+
// proves the socket was healthy and a retry-in-place can succeed. Local transport errors carry no
93+
// flags and are deliberately not retried: the connection manager owns that recovery, and a retry
94+
// through the closure-captured dead stub could never succeed anyway. Deliberately retries even
95+
// when `overloaded` is set alongside the reset — the reset destroyed the queue that was
96+
// overloaded, and one jittered attempt is not a retry loop. Never use for writes.
7497
export async function withDoResetRetry<T>(fn: () => Promise<T>, delayMs = 1500): Promise<T> {
7598
try {
7699
return await fn()
77100
} catch (err) {
78-
if (!isDurableObjectResetError(err)) throw err
101+
if (!isDurableObjectResetError(err) && !flag(err, 'retryable')) throw err
79102
await sleep(delayMs * (0.75 + Math.random() * 0.5))
80103
return fn()
81104
}

packages/workshop-shared/src/api.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,40 @@ function isOpenGadgetErrorCode(value: unknown): value is OpenGadgetErrorCode {
285285
value === OPEN_GADGET_ERROR_CODES.workspaceAccessDenied;
286286
}
287287

288+
/** Stable error codes attached to authentication failures. */
289+
export const AUTH_ERROR_CODES = {
290+
invalidSessionToken: "INVALID_SESSION_TOKEN",
291+
notAuthenticatedWithAccess: "NOT_AUTHENTICATED_WITH_ACCESS",
292+
} as const;
293+
294+
/** An expected authentication failure code. */
295+
export type AuthErrorCode = typeof AUTH_ERROR_CODES[keyof typeof AUTH_ERROR_CODES];
296+
297+
/** Messages for auth failures thrown without a surviving code; clients match these only as a
298+
* classification fallback, so changing one is a compatibility break with older deployments. */
299+
export const AUTH_ERROR_MESSAGES: Record<AuthErrorCode, string> = {
300+
[AUTH_ERROR_CODES.invalidSessionToken]: "invalid session token",
301+
[AUTH_ERROR_CODES.notAuthenticatedWithAccess]: "Not authenticated with Access.",
302+
};
303+
304+
/** Creates an authentication failure with a machine-readable code. */
305+
export function createAuthError(code: AuthErrorCode): Error & { code: AuthErrorCode } {
306+
return Object.assign(new Error(AUTH_ERROR_MESSAGES[code]), { code });
307+
}
308+
309+
/** Reads the machine-readable code from an authentication failure. */
310+
export function getAuthErrorCode(error: unknown): AuthErrorCode | undefined {
311+
if (typeof error !== "object" || error === null) return undefined;
312+
313+
const candidate = "code" in error ? error.code : undefined;
314+
return isAuthErrorCode(candidate) ? candidate : undefined;
315+
}
316+
317+
function isAuthErrorCode(value: unknown): value is AuthErrorCode {
318+
return value === AUTH_ERROR_CODES.invalidSessionToken ||
319+
value === AUTH_ERROR_CODES.notAuthenticatedWithAccess;
320+
}
321+
288322
// Top-level API exposed to the user after they have authenticated.
289323
export interface AuthenticatedApi extends RpcTarget {
290324
// Get profile info for the user who is logged in.

0 commit comments

Comments
 (0)