cloudflare / cloudflare/workers-sdk
[Workflow] Feature request: Support structured, serializable metadata on `NonRetryableError`
- Dominant language
- TypeScript
- Stars
- 4.5k
- Forks
- 1.5k
- Avg merge
- 3d 8h
- Merged PRs (30d)
- 186
Description
## Problem
Today, `NonRetryableError`'s constructor is `(message: string, name?: string)` — no way to attach structured data. When a step fails permanently with a domain-level payload (error codes, provider-specific failure details, validation info), the only channel available is the `message` string:
```ts
// Inside step.do:
throw new NonRetryableError(JSON.stringify({ code: "ACCOUNT_SUSPENDED", accountId }));
// In run():
try {
await step.do("charge", ...);
} catch (error) {
if (error instanceof NonRetryableError) {
const payload = JSON.parse(error.message); // fragile, untyped
}
}
```
This isn't hypothetical: #12636 showed a custom `NonRetryableError` message getting replaced by a generic one once it reached `instanceStatusInfo`, and #13560 fixed exactly that — but only for the plain `message`/`name` strings, and only behind the `workflows_preserve_non_retryable_error_message` compat flag so far. There's still no channel for anything beyond a string.
## Proposed solution
Add an optional, generic `data` property to `NonRetryableError`, with a guarantee that the Workflows runtime serializes and rehydrates it across the step/run boundary:
```ts
export interface NonRetryableErrorOptions {
data?: T;
cause?: unknown;
}
export class NonRetryableError extends Error {
readonly data?: T;
// Overloaded to keep the existing `(message, name?: string)` call sites working.
constructor(message: string, name?: string);
constructor(message: string, options?: NonRetryableErrorOptions);
constructor(message: string, nameOrOptions?: string | NonRetryableErrorOptions) {
const options =
typeof nameOrOptions === "string" ? { name: nameOrOptions } : nameOrOptions;
super(message, { cause: options && "cause" in options ? options.cause : undefined });
this.name = typeof nameOrOptions === "string" ? nameOrOptions : "NonRetryableError";
this.data = options && "data" in options ? options.data : undefined;
}
// Type params don't survive `instanceof` at runtime, so narrowing `error`
// to `NonRetryableError` still leaves `data: unknown`. A static guard is
// the only way to get a typed `data` back without an unchecked cast.
static is(error: unknown): error is NonRetryableError {
return error instanceof NonRetryableError;
}
}
```
Usage:
```ts
interface PaymentFailure {
code: "CARD_DECLINED" | "FRAUD_SUSPECTED";
declineCode: string;
}
async function processPayment() {
const res = await gateway.charge();
if (res.failed) {
throw new NonRetryableError("Payment permanently rejected", {
data: { code: "CARD_DECLINED", declineCode: res.rawCode },
});
}
return res;
};
try {
await step.do("process-payment", () => processPayment());
} catch (error) {
if (NonRetryableError.is(error)) {
// side effects still need their own step for replay safety
await step.do("notify-user", () => notifyUser(error.data)); // typed, and guaranteed to round-trip
}
throw error;
}
```
## Why this matters
Workflows coordinate distributed services where structured failure metadata often drives the next branch (compensation, alerting, dead-letter routing). A guaranteed, typed metadata channel on `NonRetryableError` removes the need to smuggle that data through `message` strings.
Contributor guide
Research direction
Start by locating NonRetryableError and tracing the Workflows runtime across the step.do/run boundary, including instanceStatusInfo and the workflows_preserve_non_retryable_error_message compatibility path. Done means structured data is preserved and rehydrated across that boundary while existing message/name call sites continue to work, with coverage for the proposed usage and failure cases.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend-api-design, distributed-systems
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100