cloudflare / cloudflare/workers-sdk

[Workflow] Feature request: Support structured, serializable metadata on `NonRetryableError`

Open
#15,583 1 comment 0 reactions 0 assignees View on GitHub
product:workflows
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.