diff --git a/.changeset/fatal-retryable-error-serialization.md b/.changeset/fatal-retryable-error-serialization.md new file mode 100644 index 0000000000..23c210eda5 --- /dev/null +++ b/.changeset/fatal-retryable-error-serialization.md @@ -0,0 +1,5 @@ +--- +"@workflow/core": minor +--- + +Add first-class serialization for `FatalError` and `RetryableError` so they round-trip with class identity preserved across all serialization boundaries (including from environments that don't run the SWC plugin) diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index 4e461ca981..a9809a552c 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -2,6 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; import { + FatalError, + RetryableError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowWorldError, @@ -2010,11 +2012,12 @@ describe('e2e', () => { ); test( - 'errorSubclassRoundTripWorkflow - built-in Error subclasses survive every serialization boundary', + 'errorSubclassRoundTripWorkflow - first-class Error subclasses survive every serialization boundary', { timeout: 60_000 }, async () => { - // Round-trips one instance of each built-in Error subclass that has - // a dedicated reducer/reviver pair through the full pipeline: + // Round-trips one instance of each Error subclass that has a dedicated + // reducer/reviver pair (built-in subclasses + FatalError/RetryableError + // from @workflow/errors) through the full pipeline: // // client (start args) → workflow → step → workflow → client (return) // @@ -2022,7 +2025,15 @@ describe('e2e', () => { // (devalue uses first-match-wins). A regression that drops the // ordering — or skips a subclass entirely — would silently downgrade // these to plain `Error` and break the `instanceof` assertions. + // + // FatalError and RetryableError specifically are first-class + // serialization targets (rather than going through the SWC + // `WORKFLOW_SERIALIZE` class-instance pipeline) so that they round-trip + // even from environments that don't run the SWC plugin — e.g. this + // vitest runner, which constructs them directly and passes them as + // start() arguments. const cause = new Error('underlying failure'); + const retryAfter = new Date('2099-01-01T00:00:00.000Z'); const inputs: Error[] = [ new TypeError('bad type', { cause }), new RangeError('out of range'), @@ -2034,6 +2045,8 @@ describe('e2e', () => { [new Error('inner-1'), new Error('inner-2')], 'aggregate failed' ), + new FatalError('fatal!'), + new RetryableError('try again', { retryAfter }), // Plain Error included as a control: the catch-all base reducer // must still match it after the subclass reducers above. new Error('plain error', { cause: 'string-cause' }), @@ -2062,6 +2075,8 @@ describe('e2e', () => { { ctor: ReferenceError, message: 'x is not defined' }, { ctor: EvalError, message: 'eval went wrong' }, { ctor: AggregateError, message: 'aggregate failed' }, + { ctor: FatalError, message: 'fatal!' }, + { ctor: RetryableError, message: 'try again' }, { ctor: Error, message: 'plain error', cause: 'string-cause' }, ]; @@ -2095,6 +2110,19 @@ describe('e2e', () => { expect((aggregate.errors[0] as Error).message).toBe('inner-1'); expect(aggregate.errors[1]).toBeInstanceOf(Error); expect((aggregate.errors[1] as Error).message).toBe('inner-2'); + + // FatalError must preserve its `fatal` instance property after + // round-tripping (the constructor sets it on every new instance). + const fatal = returnValue[7] as FatalError; + expect(fatal.fatal).toBe(true); + expect(FatalError.is(fatal)).toBe(true); + + // RetryableError must preserve its `retryAfter` Date with the same + // millisecond value across the realm boundary. + const retryable = returnValue[8] as RetryableError; + expect(retryable.retryAfter).toBeInstanceOf(Date); + expect(retryable.retryAfter.getTime()).toBe(retryAfter.getTime()); + expect(RetryableError.is(retryable)).toBe(true); } ); diff --git a/packages/core/src/serialization.test.ts b/packages/core/src/serialization.test.ts index 9b01e921c0..b44c34b679 100644 --- a/packages/core/src/serialization.test.ts +++ b/packages/core/src/serialization.test.ts @@ -1,5 +1,6 @@ import { runInContext } from 'node:vm'; import type { WorkflowRuntimeError } from '@workflow/errors'; +import { FatalError, RetryableError } from '@workflow/errors'; import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from '@workflow/serde'; import { beforeAll, describe, expect, it } from 'vitest'; import { registerSerializationClass } from './class-serialization.js'; @@ -1836,12 +1837,16 @@ describe('cross-VM Error serialization', () => { vmGlobalThis.WritableStream = globalThis.WritableStream; it('should serialize a host-context Error when using VM globalThis', async () => { - // This simulates the scenario where a FatalError (created in the host + // This simulates the scenario where an Error (created in the host // context) is passed as an argument to a step function. The serialization // uses VM's globalThis, so `instanceof vmGlobal.Error` would fail for // host-context errors. Using types.isNativeError() fixes this. + // + // The custom `name` is intentionally NOT one of the dedicated subclass + // names (FatalError, RetryableError, TypeError, …) so the value falls + // through to the generic Error reducer, which is the path under test. const hostError = new Error('host error'); - hostError.name = 'FatalError'; + hostError.name = 'CustomError'; const serialized = await dehydrateStepArguments( [hostError], @@ -1861,7 +1866,7 @@ describe('cross-VM Error serialization', () => { // The reviver creates errors with `new global.Error()` (VM's Error), // so `instanceof` against the host Error fails. Check duck-type instead. - expect((hydrated[0] as Error).name).toBe('FatalError'); + expect((hydrated[0] as Error).name).toBe('CustomError'); expect((hydrated[0] as Error).message).toBe('host error'); // Verify it's an instance of the VM's Error vmGlobalThis.__testVal = hydrated[0]; @@ -1869,8 +1874,10 @@ describe('cross-VM Error serialization', () => { }); it('should serialize a VM-context Error when using VM globalThis', async () => { + // See note on the previous test about the custom `name` being + // intentionally chosen to bypass the dedicated subclass reducers. const vmError = runInContext( - '(() => { const e = new Error("vm error"); e.name = "FatalError"; return e; })()', + '(() => { const e = new Error("vm error"); e.name = "CustomError"; return e; })()', context ); @@ -1892,7 +1899,7 @@ describe('cross-VM Error serialization', () => { // The reviver creates errors with `new global.Error()` (VM's Error), // so `instanceof` against the host Error fails. Check duck-type instead. - expect((hydrated[0] as Error).name).toBe('FatalError'); + expect((hydrated[0] as Error).name).toBe('CustomError'); expect((hydrated[0] as Error).message).toBe('vm error'); // Verify it's an instance of the VM's Error vmGlobalThis.__testVal = hydrated[0]; @@ -1900,13 +1907,16 @@ describe('cross-VM Error serialization', () => { }); it('should serialize Error subclass from host context through workflow reducers', async () => { - class FatalError extends Error { + // User-defined subclass with a non-special `name` so the value flows + // through the generic Error reducer (which uses `new global.Error(...)`) + // rather than one of the dedicated subclass reducers. + class CustomError extends Error { constructor(message: string) { super(message); - this.name = 'FatalError'; + this.name = 'CustomError'; } } - const error = new FatalError('step failed'); + const error = new CustomError('step failed'); const serialized = await dehydrateStepArguments( { error }, @@ -1925,7 +1935,7 @@ describe('cross-VM Error serialization', () => { )) as { error: Error }; // The reviver creates errors with `new global.Error()` (VM's Error) - expect(hydrated.error.name).toBe('FatalError'); + expect(hydrated.error.name).toBe('CustomError'); expect(hydrated.error.message).toBe('step failed'); // Verify it's an instance of the VM's Error vmGlobalThis.__testVal = hydrated.error; @@ -3498,6 +3508,147 @@ describe('DOMException serialization', () => { }); }); +describe('FatalError and RetryableError serialization', () => { + // FatalError and RetryableError are first-class serialization targets + // (handled by dedicated reducers/revivers in the common reducers module), + // so unlike user-defined classes they round-trip without any + // `registerSerializationClass` setup. This is what makes them usable + // from environments that don't run the SWC plugin (e.g. the vitest e2e + // runner, ad-hoc Node scripts, etc.). + + async function roundTrip(value: unknown) { + const serialized = await dehydrateStepReturnValue( + value, + mockRunId, + noEncryptionKey + ); + return hydrateStepReturnValue( + serialized, + mockRunId, + noEncryptionKey, + globalThis + ); + } + + it('should round-trip FatalError preserving type and message', async () => { + const error = new FatalError('step failed permanently'); + const hydrated = (await roundTrip(error)) as FatalError; + expect(hydrated).toBeInstanceOf(FatalError); + expect(hydrated.message).toBe('step failed permanently'); + expect(hydrated.fatal).toBe(true); + expect(hydrated.name).toBe('FatalError'); + expect(FatalError.is(hydrated)).toBe(true); + }); + + it('should round-trip FatalError preserving stack', async () => { + const error = new FatalError('with stack'); + const originalStack = error.stack; + const hydrated = (await roundTrip(error)) as FatalError; + expect(hydrated.stack).toBe(originalStack); + }); + + it('should serialize FatalError using its dedicated reducer key', async () => { + const error = new FatalError('test'); + const serialized = await dehydrateStepReturnValue( + error, + mockRunId, + noEncryptionKey + ); + const str = new TextDecoder().decode( + (serialized as Uint8Array).subarray(4) + ); + // devalue marks reduced values with `["KeyName",N]` arrays. Asserting on + // the literal marker (not just the string "FatalError") proves the + // dedicated FatalError reducer matched, rather than the generic Error + // reducer producing a payload that happens to contain "FatalError" in + // the `name` field. + expect(str).toContain('["FatalError",'); + expect(str).not.toContain('["Error",'); + expect(str).not.toContain('Instance'); + }); + + it('should round-trip RetryableError preserving type, message, and retryAfter', async () => { + const retryDate = new Date('2025-01-01T00:00:00.000Z'); + const error = new RetryableError('temporary failure', { + retryAfter: retryDate, + }); + const hydrated = (await roundTrip(error)) as RetryableError; + expect(hydrated).toBeInstanceOf(RetryableError); + expect(hydrated.message).toBe('temporary failure'); + expect(hydrated.name).toBe('RetryableError'); + expect(hydrated.retryAfter).toBeInstanceOf(Date); + expect(hydrated.retryAfter.toISOString()).toBe('2025-01-01T00:00:00.000Z'); + expect(RetryableError.is(hydrated)).toBe(true); + }); + + it('should round-trip RetryableError preserving stack', async () => { + const error = new RetryableError('with stack'); + const originalStack = error.stack; + const hydrated = (await roundTrip(error)) as RetryableError; + expect(hydrated.stack).toBe(originalStack); + }); + + it('should serialize RetryableError using its dedicated reducer key', async () => { + const error = new RetryableError('test'); + const serialized = await dehydrateStepReturnValue( + error, + mockRunId, + noEncryptionKey + ); + const str = new TextDecoder().decode( + (serialized as Uint8Array).subarray(4) + ); + // See note on the FatalError variant above: assert on the devalue + // marker `["KeyName",N]` to prove the dedicated reducer matched. + expect(str).toContain('["RetryableError",'); + expect(str).not.toContain('["Error",'); + expect(str).not.toContain('Instance'); + }); + + it('should preserve cause on FatalError when present', async () => { + const cause = new Error('underlying issue'); + const error = new FatalError('fatal with cause'); + (error as Error).cause = cause; + const hydrated = (await roundTrip(error)) as FatalError; + expect(hydrated).toBeInstanceOf(FatalError); + expect(hydrated.message).toBe('fatal with cause'); + expect((hydrated as Error).cause).toBeInstanceOf(Error); + expect(((hydrated as Error).cause as Error).message).toBe( + 'underlying issue' + ); + }); + + it('should not set cause on hydrated FatalError when original had no cause', async () => { + const error = new FatalError('no cause'); + expect('cause' in error).toBe(false); + const hydrated = (await roundTrip(error)) as FatalError; + expect('cause' in hydrated).toBe(false); + }); + + it('should preserve cause on RetryableError when present', async () => { + const cause = new Error('underlying retry issue'); + const error = new RetryableError('retry with cause'); + (error as Error).cause = cause; + const hydrated = (await roundTrip(error)) as RetryableError; + expect(hydrated).toBeInstanceOf(RetryableError); + expect(hydrated.message).toBe('retry with cause'); + expect((hydrated as Error).cause).toBeInstanceOf(Error); + expect(((hydrated as Error).cause as Error).message).toBe( + 'underlying retry issue' + ); + }); + + it('should round-trip RetryableError with retryAfter Date that has specific value', async () => { + const retryDate = new Date('2099-12-31T23:59:59.999Z'); + const error = new RetryableError('future retry', { + retryAfter: retryDate, + }); + const hydrated = (await roundTrip(error)) as RetryableError; + expect(hydrated.retryAfter).toBeInstanceOf(Date); + expect(hydrated.retryAfter.getTime()).toBe(retryDate.getTime()); + }); +}); + describe('format prefix system', () => { const { globalThis: vmGlobalThis } = createContext({ seed: 'test', diff --git a/packages/core/src/serialization/reducers/common.ts b/packages/core/src/serialization/reducers/common.ts index 0bc9c57cf6..3924dcfaef 100644 --- a/packages/core/src/serialization/reducers/common.ts +++ b/packages/core/src/serialization/reducers/common.ts @@ -10,6 +10,7 @@ */ import { types } from 'node:util'; +import { FatalError, RetryableError } from '@workflow/errors'; import type { Reducers, Revivers, SerializableSpecial } from '../types.js'; // ---- Base64 helpers ---- @@ -53,28 +54,91 @@ function revive(str: string) { // ---- Error subclass helpers ---- /** - * Creates a reducer for a built-in Error subclass. Each subclass reducer - * checks that the value is a native error with a matching constructor name, - * then serializes `message`, `stack`, and optionally `cause`. + * The shared shape that every Error-subclass reducer in this module + * produces. Some subclasses (e.g. `AggregateError`, `RetryableError`) extend + * this with additional fields by spreading the base payload. + */ +type BaseErrorPayload = { + message: string; + stack?: string; + cause?: unknown; +}; + +/** + * Subset of `SerializableSpecial` keys whose payload shape is exactly the + * `BaseErrorPayload`. `makeErrorSubclassReducer` is constrained to only + * these keys so its return type is sound — subclasses that need extra + * fields (like `AggregateError.errors` or `RetryableError.retryAfter`) use + * `reduceErrorBase` directly and extend the result. + */ +type SimpleErrorSubclassKey = { + [K in keyof SerializableSpecial]: SerializableSpecial[K] extends BaseErrorPayload + ? BaseErrorPayload extends SerializableSpecial[K] + ? K + : never + : never; +}[keyof SerializableSpecial]; + +/** + * Reduces any native Error instance to the shared `BaseErrorPayload` shape, + * preserving `cause` only when present (to distinguish "no cause" from + * "cause is undefined"). Used directly by reducers for subclasses that need + * to extend the shape with additional fields. * * `types.isNativeError()` is used instead of `instanceof` for cross-VM safety: * errors may originate from a different VM context, and `instanceof` fails * across VM boundaries since each context has its own Error constructor. - * After the isNativeError gate, we use the constructor name to distinguish - * subclasses (since `instanceof` may fail across VM boundaries). */ -function makeErrorSubclassReducer( +function reduceErrorBase(value: unknown): BaseErrorPayload | false { + if (!types.isNativeError(value)) return false; + const reduced: BaseErrorPayload = { + message: value.message, + stack: value.stack, + }; + if ('cause' in value) reduced.cause = (value as { cause: unknown }).cause; + return reduced; +} + +/** + * Reduces a native error to the shared `BaseErrorPayload`, but only when its + * `name` instance property matches `subclassName`. Used by: + * - `makeErrorSubclassReducer`, for subclasses whose serialized shape is + * exactly `BaseErrorPayload`. + * - Inline reducers for subclasses that extend the shape with additional + * fields (e.g. `AggregateError.errors`, `RetryableError.retryAfter`). + * + * Matching by `value.name` (instead of `value.constructor?.name`) is robust + * to bundlers that emit the class as an anonymous expression — e.g. Turbopack + * compiles `export class FatalError extends Error {…}` to a registration call + * like `e.s(["FatalError", 0, class extends Error {…}])`, and the resulting + * constructor has `name === ''`. Since every Error subclass we care about + * sets `this.name` explicitly in its constructor (built-in subclasses do this + * automatically; `FatalError`/`RetryableError` do it in user code), the + * instance property is the reliable identity marker across realms and + * bundlers. + */ +function reduceNamedErrorSubclassBase( + subclassName: string, + value: unknown +): BaseErrorPayload | false { + if (!types.isNativeError(value)) return false; + if (value.name !== subclassName) return false; + return reduceErrorBase(value); +} + +/** + * Creates a reducer for a built-in Error subclass whose serialized shape is + * exactly `BaseErrorPayload` (no extra fields). The reducer matches by + * constructor name (after the isNativeError gate), since `instanceof` may + * fail across VM boundaries. + */ +function makeErrorSubclassReducer( subclassName: K ) { - return (value: any) => { - if (!types.isNativeError(value)) return false; - if (value.constructor?.name !== subclassName) return false; - const reduced: Record = { - message: value.message, - stack: value.stack, - }; - if ('cause' in value) reduced.cause = value.cause; - return reduced as SerializableSpecial[K]; + return (value: unknown): SerializableSpecial[K] | false => { + const base = reduceNamedErrorSubclassBase(subclassName, value); + if (!base) return false; + return base as SerializableSpecial[K]; }; } @@ -89,7 +153,7 @@ function makeErrorSubclassReviver( ctorName: string ) { return (value: SerializableSpecial[K]) => { - const v = value as { message: string; stack?: string; cause?: unknown }; + const v = value as BaseErrorPayload; const opts = 'cause' in v ? { cause: v.cause } : undefined; const Ctor = global[ctorName]; const error: Error = new Ctor(v.message, opts); @@ -138,15 +202,46 @@ export function getCommonReducers( // rather than falling through to the generic "Error" reducer. // See `makeErrorSubclassReducer` for implementation details. EvalError: makeErrorSubclassReducer('EvalError'), + FatalError: makeErrorSubclassReducer('FatalError'), RangeError: makeErrorSubclassReducer('RangeError'), ReferenceError: makeErrorSubclassReducer('ReferenceError'), + // RetryableError carries an extra `retryAfter` Date that we serialize as + // a numeric epoch timestamp. The Date reducer uses `instanceof global.Date`, + // which fails for Dates from a different VM realm; serializing as a + // number sidesteps that issue. + RetryableError: (value) => { + const base = reduceNamedErrorSubclassBase('RetryableError', value); + if (!base) return false; + const retryAfterRaw = (value as RetryableError).retryAfter as unknown; + let retryAfter: number; + if ( + retryAfterRaw && + typeof retryAfterRaw === 'object' && + typeof (retryAfterRaw as { getTime?: unknown }).getTime === 'function' + ) { + const t = (retryAfterRaw as Date).getTime(); + retryAfter = Number.isNaN(t) ? Date.now() + 1000 : t; + } else if ( + typeof retryAfterRaw === 'string' || + typeof retryAfterRaw === 'number' + ) { + const t = new Date(retryAfterRaw).getTime(); + retryAfter = Number.isNaN(t) ? Date.now() + 1000 : t; + } else { + retryAfter = Date.now() + 1000; + } + return { + ...base, + retryAfter, + } satisfies SerializableSpecial['RetryableError']; + }, SyntaxError: makeErrorSubclassReducer('SyntaxError'), TypeError: makeErrorSubclassReducer('TypeError'), URIError: makeErrorSubclassReducer('URIError'), // AggregateError is similar to other subclasses but also preserves the // `errors` array. We extend the base helper's output here. AggregateError: (value) => { - const base = makeErrorSubclassReducer('AggregateError')(value); + const base = reduceNamedErrorSubclassBase('AggregateError', value); if (!base) return false; return { ...base, @@ -240,8 +335,28 @@ export function getCommonRevivers( // Error subclass revivers reconstruct the correct built-in Error type. // See `makeErrorSubclassReviver` for implementation details. EvalError: makeErrorSubclassReviver(global, 'EvalError'), + // FatalError and RetryableError are imported directly rather than read + // from `global` because they are not built-ins; they live in the + // `@workflow/errors` package which is bundled into every context. + FatalError: (value) => { + const error = new FatalError(value.message); + if (value.stack !== undefined) error.stack = value.stack; + if ('cause' in value) error.cause = value.cause; + return error; + }, RangeError: makeErrorSubclassReviver(global, 'RangeError'), ReferenceError: makeErrorSubclassReviver(global, 'ReferenceError'), + RetryableError: (value) => { + // Use the context's `Date` constructor (matching the rest of this + // module) so the resulting `retryAfter` Date passes `instanceof + // global.Date` checks in the target realm. + const error = new RetryableError(value.message, { + retryAfter: new global.Date(value.retryAfter), + }); + if (value.stack !== undefined) error.stack = value.stack; + if ('cause' in value) error.cause = value.cause; + return error; + }, SyntaxError: makeErrorSubclassReviver(global, 'SyntaxError'), TypeError: makeErrorSubclassReviver(global, 'TypeError'), URIError: makeErrorSubclassReviver(global, 'URIError'), diff --git a/packages/core/src/serialization/types.ts b/packages/core/src/serialization/types.ts index c345183637..4c14f82bff 100644 --- a/packages/core/src/serialization/types.ts +++ b/packages/core/src/serialization/types.ts @@ -51,6 +51,7 @@ export interface SerializableSpecial { stack?: string; cause?: unknown; }; + FatalError: { message: string; stack?: string; cause?: unknown }; Float32Array: string; // base64 string Float64Array: string; // base64 string Error: { name: string; message: string; stack?: string; cause?: unknown }; @@ -66,6 +67,17 @@ export interface SerializableSpecial { | { bodyInit: any }; ReferenceError: { message: string; stack?: string; cause?: unknown }; RegExp: { source: string; flags: string }; + /** + * `retryAfter` is serialized as a numeric epoch timestamp rather than a + * `Date` to be realm-safe. The Date reducer uses `instanceof global.Date`, + * which fails for Dates from a different VM realm. + */ + RetryableError: { + message: string; + stack?: string; + cause?: unknown; + retryAfter: number; + }; Request: { method: string; url: string;