Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fatal-retryable-error-serialization.md
Original file line number Diff line number Diff line change
@@ -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)
34 changes: 31 additions & 3 deletions packages/core/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2010,19 +2012,28 @@ 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)
//
// Each subclass reducer must run before the generic `Error` reducer
// (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'),
Expand All @@ -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' }),
Expand Down Expand Up @@ -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' },
];

Expand Down Expand Up @@ -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);
}
);

Expand Down
169 changes: 160 additions & 9 deletions packages/core/src/serialization.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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],
Expand All @@ -1861,16 +1866,18 @@ 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];
expect(runInContext('__testVal instanceof Error', context)).toBe(true);
});

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
);

Expand All @@ -1892,21 +1899,24 @@ 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];
expect(runInContext('__testVal instanceof Error', context)).toBe(true);
});

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 },
Expand All @@ -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;
Expand Down Expand Up @@ -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');
Comment thread
TooTallNate marked this conversation as resolved.
});

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',
Expand Down
Loading
Loading