Add first-class serialization for FatalError and RetryableError#1513
Add first-class serialization for FatalError and RetryableError#1513TooTallNate wants to merge 2 commits intomainfrom
Conversation
🦋 Changeset detectedLatest commit: 728d056 The changes in this PR will be included in the next version bump. This PR includes changesets to release 20 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🧪 E2E Test Results❌ Some tests failed Summary
❌ Failed Tests▲ Vercel Production (22 failed)astro (2 failed):
example (2 failed):
express (2 failed):
fastify (2 failed):
hono (2 failed):
nextjs-turbopack (2 failed):
nextjs-webpack (2 failed):
nitro (2 failed):
nuxt (2 failed):
sveltekit (2 failed):
vite (2 failed):
💻 Local Development (22 failed)astro-stable (2 failed):
express-stable (2 failed):
fastify-stable (2 failed):
hono-stable (2 failed):
nextjs-turbopack-canary (2 failed):
nextjs-turbopack-stable (2 failed):
nextjs-webpack-stable (2 failed):
nitro-stable (2 failed):
nuxt-stable (2 failed):
sveltekit-stable (2 failed):
vite-stable (2 failed):
📦 Local Production (24 failed)astro-stable (2 failed):
express-stable (2 failed):
fastify-stable (2 failed):
hono-stable (2 failed):
nextjs-turbopack-canary (2 failed):
nextjs-turbopack-stable (2 failed):
nextjs-webpack-canary (2 failed):
nextjs-webpack-stable (2 failed):
nitro-stable (2 failed):
nuxt-stable (2 failed):
sveltekit-stable (2 failed):
vite-stable (2 failed):
🐘 Local Postgres (24 failed)astro-stable (2 failed):
express-stable (2 failed):
fastify-stable (2 failed):
hono-stable (2 failed):
nextjs-turbopack-canary (2 failed):
nextjs-turbopack-stable (2 failed):
nextjs-webpack-canary (2 failed):
nextjs-webpack-stable (2 failed):
nitro-stable (2 failed):
nuxt-stable (2 failed):
sveltekit-stable (2 failed):
vite-stable (2 failed):
🪟 Windows (2 failed)nextjs-turbopack (2 failed):
🌍 Community Worlds (67 failed)mongodb (5 failed):
redis (4 failed):
turso (58 failed):
📋 Other (6 failed)e2e-local-dev-nest-stable (2 failed):
e2e-local-postgres-nest-stable (2 failed):
e2e-local-prod-nest-stable (2 failed):
Details by Category❌ ▲ Vercel Production
❌ 💻 Local Development
❌ 📦 Local Production
❌ 🐘 Local Postgres
❌ 🪟 Windows
❌ 🌍 Community Worlds
❌ 📋 Other
❌ Some E2E test jobs failed:
Check the workflow run for details. |
There was a problem hiding this comment.
Pull request overview
Adds first-class devalue serialization support for Workflow DevKit error types FatalError and RetryableError, improving type preservation across serialization boundaries.
Changes:
- Add
FatalError/RetryableErrorreducers and revivers to the common devalue pipeline. - Extend serialization tests with new round-trip coverage for both error types and adjust cross-VM FatalError expectations.
- Add a changeset to release the update as a patch to
@workflow/core.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| packages/core/src/serialization.ts | Introduces new special reducers/revivers for FatalError and RetryableError in the common serialization pipeline. |
| packages/core/src/serialization.test.ts | Updates cross-VM FatalError assertions and adds new round-trip tests for Fatal/Retryable errors. |
| .changeset/fatal-retryable-error-serialization.md | Declares a patch release note for the new serialization behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (!FatalError.is(value)) return false; | ||
| return { | ||
| message: value.message, | ||
| stack: value.stack, |
There was a problem hiding this comment.
FatalError currently serializes only {message, stack}. Since this reducer runs before the generic Error reducer, any FatalError with an assigned cause will now lose its cause chain during serialization/deserialization. Consider including cause in the serialized payload and restoring it on the revived error (e.g., assign error.cause when present).
| stack: value.stack, | |
| stack: value.stack, | |
| // Preserve the error cause chain when present | |
| cause: (value as any).cause, |
| return { | ||
| message: value.message, | ||
| stack: value.stack, | ||
| retryAfter: (value as RetryableError).retryAfter, |
There was a problem hiding this comment.
RetryableError reducer assumes retryAfter exists and is a Date from the same realm. Since RetryableError.is() only checks value.name, any Error named 'RetryableError' without a valid date-like retryAfter can be captured and cause hydration to throw. Additionally, when serializing across VM realms, retryAfter may be a host-realm Date that fails the Date reducer’s instanceof global.Date check, so it won’t be revived as a Date. Add validation that retryAfter is date-like and normalize it to a global.Date (or serialize it as an ISO string) before returning it; otherwise return false to fall back to the generic Error reducer. Also consider serializing/restoring cause here to avoid regressing from the generic Error reducer’s cause preservation.
| return { | |
| message: value.message, | |
| stack: value.stack, | |
| retryAfter: (value as RetryableError).retryAfter, | |
| const retryAfterRaw = (value as RetryableError).retryAfter as unknown; | |
| let timestamp: number | null = null; | |
| if ( | |
| retryAfterRaw && | |
| typeof retryAfterRaw === 'object' && | |
| typeof (retryAfterRaw as { getTime?: unknown }).getTime === 'function' | |
| ) { | |
| const t = (retryAfterRaw as Date).getTime(); | |
| if (!Number.isNaN(t)) timestamp = t; | |
| } else if ( | |
| typeof retryAfterRaw === 'string' || | |
| typeof retryAfterRaw === 'number' | |
| ) { | |
| const t = new Date(retryAfterRaw).getTime(); | |
| if (!Number.isNaN(t)) timestamp = t; | |
| } | |
| if (timestamp === null) { | |
| // Not a valid date-like retryAfter; fall back to generic Error reducer. | |
| return false; | |
| } | |
| const retryAfter = new Date(timestamp); | |
| const cause = | |
| 'cause' in (value as Error) ? (value as Error & { cause?: unknown }).cause : undefined; | |
| return { | |
| message: value.message, | |
| stack: value.stack, | |
| retryAfter, | |
| ...(cause !== undefined ? { cause } : {}), |
| // Use the context-specific FatalError if available (e.g. workflow VM), | ||
| // otherwise fall back to a plain Error with the FatalError name. | ||
| // This ensures the deserialized error is instanceof the correct context's Error. |
There was a problem hiding this comment.
The FatalError reviver comment says it falls back to a plain Error and that this guarantees instanceof the context's Error. The implementation actually falls back to the imported host FatalError class, which may be from a different realm than global.Error. Please update the comment to match the behavior (context ctor if provided; otherwise host ctor, and rely on FatalError.is() for cross-realm checks).
| // Use the context-specific FatalError if available (e.g. workflow VM), | |
| // otherwise fall back to a plain Error with the FatalError name. | |
| // This ensures the deserialized error is instanceof the correct context's Error. | |
| // Prefer the context-specific FatalError constructor if available (e.g. workflow VM), | |
| // otherwise fall back to the imported host FatalError constructor. | |
| // Cross-realm identification should rely on FatalError.is() rather than instanceof. |
c735685 to
a30a601
Compare
a14b17e to
6ea2e0e
Compare
7b08980 to
b27fa71
Compare
7a43bf9 to
909f31f
Compare
b27fa71 to
1c8650d
Compare
| /** Test: RetryableError preserves class identity through step error serialization */ | ||
| async function throwRetryableErrorStep() { | ||
| 'use step'; | ||
| throw new RetryableError('retryable serde test', { |
There was a problem hiding this comment.
The errorRetryableSerdeRoundTrip e2e test will always time out because it throws a RetryableError with retryAfter set to year 2099 (causing ~74-year retry delay), and even if retries were exhausted, the step_failed path always reconstructs the error as FatalError (not RetryableError), causing all test assertions to fail.
- Pass DOMException through to the workflow VM context alongside other Web APIs (Headers, URL, TextEncoder, etc.) - Add DOMException reducer/reviver to the serialization pipeline, preserving message, name, derived code, and cause when present - Add DOMException to the Serializable type union - 8 new tests: round-trip for AbortError, NotFoundError, default name, cause preservation, cause absence, serialization key, cross-VM boundaries, and VM context availability
…leError Add custom serialization methods to FatalError and RetryableError in @workflow/errors, enabling the SWC plugin to discover and register them through the standard class serialization pipeline. This preserves class identity (instanceof), the fatal flag, and the retryAfter date when these errors cross serialization boundaries. - Add @workflow/serde dependency to @workflow/errors - Add WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE static methods to both classes - Add unit tests verifying Instance-based round-trip serialization - Add e2e workflow tests verifying class identity preservation end-to-end
Summary
WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZEstatic methods toFatalErrorandRetryableErrorin@workflow/errors@workflow/serdeas a dependency of@workflow/errorsinstanceof FatalError,instanceof RetryableError)Context
This is PR 3 of 5 in a series to improve error handling in Workflow DevKit. Stacked on top of #1512.
DOMExceptionserde supportFatalErrorandRetryableErrorserdeDesign
Rather than adding explicit reducer/reviver entries to the serialization pipeline (the approach in PRs 1-2 for built-in JS types),
FatalErrorandRetryableErroruse the custom class serialization flow — the same mechanism user-defined classes use:static [WORKFLOW_SERIALIZE]()andstatic [WORKFLOW_DESERIALIZE]()methodsclassId+ registration IIFEs in each bundle context (step, workflow, client)Instancereducer/reviver handles serialization and deserialization automaticallyinstanceofworks in all contextsThis is the right approach because:
FatalErrorandRetryableErrorare not global constructors (unlikeTypeError,DOMException) — they're named exports from@workflow/errors@workflow/core's serialization pipeline — theInstanceflow handles everythingTest plan
serialization.test.tsverifying round-trip serialization for both classes (type preservation, stack, retryAfter, Instance serialization key)99_e2e.ts+e2e.test.tsverifying class identity preservation (instanceof,FatalError.is(), custom properties) through the full step → workflow error boundarytscNote: The e2e tests require a running dev server with the SWC plugin active to validate the full discovery → registration → serialization → deserialization pipeline.