Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .changeset/describe-error-subpath.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@workflow/core": patch
---

Expose `describeError` and a new data-driven `describeRunError` helper under
the `@workflow/core/describe-error` subpath. `describeRunError` takes
`{ errorCode, errorName }` fields (as they appear on persisted failure
events) and returns the same `{ attribution, hint }` description, so CLI
and web observability renderers can derive user-vs-SDK framing without
needing the original `Error` instance.
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@
"types": "./dist/encryption.d.ts",
"default": "./dist/encryption.js"
},
"./describe-error": {
"types": "./dist/describe-error.d.ts",
"default": "./dist/describe-error.js"
},
"./_workflow": "./dist/workflow/index.js"
},
"scripts": {
Expand Down
71 changes: 70 additions & 1 deletion packages/core/src/describe-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
NotInStepContextError,
NotInWorkflowContextError,
} from './context-errors.js';
import { describeError } from './describe-error.js';
import { describeError, describeRunError } from './describe-error.js';

describe('describeError', () => {
test('plain user errors are attributed to the user with no hint', () => {
Expand Down Expand Up @@ -98,3 +98,72 @@ describe('describeError', () => {
expect(result.attribution).toBe('sdk');
});
});

describe('describeRunError', () => {
test('plain user error event fields are attributed to the user with no hint', () => {
const result = describeRunError({
errorCode: RUN_ERROR_CODES.USER_ERROR,
errorName: 'Error',
});
expect(result.attribution).toBe('user');
expect(result.hint).toBeUndefined();
});

test('SerializationError by name is attributed to the user with a hint', () => {
const result = describeRunError({
errorCode: RUN_ERROR_CODES.USER_ERROR,
errorName: 'SerializationError',
});
expect(result.attribution).toBe('user');
expect(result.hint).toContain('serialized');
});

test('context-violation error names are attributed to the user', () => {
const result = describeRunError({
errorCode: RUN_ERROR_CODES.USER_ERROR,
errorName: 'NotInWorkflowContextError',
});
expect(result.attribution).toBe('user');
expect(result.hint).toContain('wrong context');
});

test('WorkflowRuntimeError name is attributed to the SDK', () => {
const result = describeRunError({
errorCode: RUN_ERROR_CODES.RUNTIME_ERROR,
errorName: 'WorkflowRuntimeError',
});
expect(result.attribution).toBe('sdk');
expect(result.hint).toContain('internal workflow SDK error');
});

test('REPLAY_TIMEOUT errorCode is attributed to the SDK', () => {
const result = describeRunError({
errorCode: RUN_ERROR_CODES.REPLAY_TIMEOUT,
});
expect(result.attribution).toBe('sdk');
expect(result.hint).toContain('replay took too long');
});

test('MAX_DELIVERIES_EXCEEDED errorCode is attributed to the SDK', () => {
const result = describeRunError({
errorCode: RUN_ERROR_CODES.MAX_DELIVERIES_EXCEEDED,
});
expect(result.attribution).toBe('sdk');
expect(result.hint).toContain('max-delivery budget');
});

test('RUNTIME_ERROR code without errorName still lands as SDK', () => {
const result = describeRunError({
errorCode: RUN_ERROR_CODES.RUNTIME_ERROR,
});
expect(result.attribution).toBe('sdk');
expect(result.hint).toContain('internal workflow SDK error');
});

test('missing errorCode defaults to USER_ERROR', () => {
const result = describeRunError({});
expect(result.attribution).toBe('user');
expect(result.errorCode).toBe(RUN_ERROR_CODES.USER_ERROR);
expect(result.hint).toBeUndefined();
});
});
90 changes: 85 additions & 5 deletions packages/core/src/describe-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,32 @@ export interface ErrorDescription {
hint?: string;
}

/**
* Error signal fields carried on persisted failure events (e.g.
* `run_failed` / `step_failed`). The shape is intentionally loose:
*
* - `errorCode` is typed as `string` rather than `RunErrorCode` because
* the value comes from stored JSON/CBOR and may predate the current
* enum — callers should not narrow on it blindly. Values that don't
* match a known `RUN_ERROR_CODES` entry fall through to USER_ERROR.
* - `errorName` is the thrown `Error#name`. It is not universally
* persisted today; callers that have access to it (either via an
* in-memory throw or a richer payload) can pass it in to sharpen
* the attribution and hint. When absent, `describeRunError` still
* returns a sensible attribution from `errorCode` alone.
*/
export interface PersistedErrorSignal {
errorCode?: string;
errorName?: string;
}

const CONTEXT_ERROR_NAMES = new Set([
'NotInWorkflowContextError',
'NotInStepContextError',
'NotInWorkflowOrStepContextError',
'UnavailableInWorkflowContextError',
]);

function isContextViolationError(err: unknown): boolean {
return (
err instanceof NotInWorkflowContextError ||
Expand All @@ -44,6 +70,60 @@ function isContextViolationError(err: unknown): boolean {
);
}

const SERIALIZATION_ERROR_HINT =
'A value passed across a workflow/step boundary could not be serialized. See the error message for the offending path and the Learn More link for details.';
const CONTEXT_ERROR_HINT =
'A workflow-only or step-only API was called from the wrong context. The error message includes the exact API and how to move the call.';
const RUNTIME_ERROR_HINT =
'This is an internal workflow SDK error, not a bug in your code. If it keeps happening, please report it with the stack trace and the runId.';
const REPLAY_TIMEOUT_HINT =
'The workflow replay took too long. This usually means the event log is unusually large or the workflow function is doing heavy synchronous work between step boundaries.';
const MAX_DELIVERIES_HINT =
'The workflow queue exceeded its max-delivery budget. This usually indicates a persistent runtime failure — check the most recent stack traces for the underlying cause.';

function normalizeErrorCode(code: string | undefined): RunErrorCode {
// Values read back from persisted events are `string | undefined` — we
// only trust codes that match a known entry in `RUN_ERROR_CODES`.
const known = Object.values(RUN_ERROR_CODES) as readonly string[];
if (code && known.includes(code)) {
return code as RunErrorCode;
}
return RUN_ERROR_CODES.USER_ERROR;
}

/**
* Data-driven variant of {@link describeError} that works from persisted
* event fields instead of a live `Error` instance. Intended for CLI/web
* renderers that read failure events and no longer have the original
* thrown object.
*/
export function describeRunError(
signal: PersistedErrorSignal
): ErrorDescription {
const errorCode = normalizeErrorCode(signal.errorCode);
const name = signal.errorName;

if (name === 'SerializationError') {
return { attribution: 'user', errorCode, hint: SERIALIZATION_ERROR_HINT };
}
if (name && CONTEXT_ERROR_NAMES.has(name)) {
return { attribution: 'user', errorCode, hint: CONTEXT_ERROR_HINT };
}
if (name === 'WorkflowRuntimeError' || name === 'StepNotRegisteredError') {
return { attribution: 'sdk', errorCode, hint: RUNTIME_ERROR_HINT };
}
if (errorCode === RUN_ERROR_CODES.REPLAY_TIMEOUT) {
return { attribution: 'sdk', errorCode, hint: REPLAY_TIMEOUT_HINT };
}
if (errorCode === RUN_ERROR_CODES.MAX_DELIVERIES_EXCEEDED) {
return { attribution: 'sdk', errorCode, hint: MAX_DELIVERIES_HINT };
}
if (errorCode === RUN_ERROR_CODES.RUNTIME_ERROR) {
return { attribution: 'sdk', errorCode, hint: RUNTIME_ERROR_HINT };
}
return { attribution: 'user', errorCode };
}

/**
* Describe an error for user-facing presentation. Purely informational —
* does not change any persisted event data or error classification used by
Expand Down Expand Up @@ -75,39 +155,39 @@ export function describeError(
return {
attribution: 'user',
errorCode: effectiveCode,
hint: 'A value passed across a workflow/step boundary could not be serialized. See the error message for the offending path and the Learn More link for details.',
hint: SERIALIZATION_ERROR_HINT,
};
}

if (isContextViolationError(err)) {
return {
attribution: 'user',
errorCode: effectiveCode,
hint: 'A workflow-only or step-only API was called from the wrong context. The error message includes the exact API and how to move the call.',
hint: CONTEXT_ERROR_HINT,
};
}

if (err instanceof WorkflowRuntimeError) {
return {
attribution: 'sdk',
errorCode: effectiveCode,
hint: 'This is an internal workflow SDK error, not a bug in your code. If it keeps happening, please report it with the stack trace and the runId.',
hint: RUNTIME_ERROR_HINT,
};
}

if (effectiveCode === RUN_ERROR_CODES.REPLAY_TIMEOUT) {
return {
attribution: 'sdk',
errorCode: effectiveCode,
hint: 'The workflow replay took too long. This usually means the event log is unusually large or the workflow function is doing heavy synchronous work between step boundaries.',
hint: REPLAY_TIMEOUT_HINT,
};
}

if (effectiveCode === RUN_ERROR_CODES.MAX_DELIVERIES_EXCEEDED) {
return {
attribution: 'sdk',
errorCode: effectiveCode,
hint: 'The workflow queue exceeded its max-delivery budget. This usually indicates a persistent runtime failure — check the most recent stack traces for the underlying cause.',
hint: MAX_DELIVERIES_HINT,
};
}

Expand Down
Loading