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
7 changes: 7 additions & 0 deletions .changeset/runtime-schema-validation-failure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@workflow/core": patch
"@workflow/errors": patch
"@workflow/world-vercel": patch
---

Record fatal world response contract failures as non-retryable workflow errors.
22 changes: 22 additions & 0 deletions packages/core/src/classify-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,28 @@ describe('classifyRunError', () => {
).toBe(RUN_ERROR_CODES.USER_ERROR);
});

it('classifies world schema validation failures as WORLD_CONTRACT_ERROR', () => {
expect(
classifyRunError(
new WorkflowWorldError(
'Schema validation failed for POST /v3/runs/wrun/events',
{ code: 'SCHEMA_VALIDATION' }
)
)
).toBe(RUN_ERROR_CODES.WORLD_CONTRACT_ERROR);
});

it('classifies world response parse failures as WORLD_CONTRACT_ERROR', () => {
expect(
classifyRunError(
new WorkflowWorldError(
'Failed to parse response body for GET /v3/runs/wrun/events',
{ code: 'PARSE_ERROR' }
)
)
).toBe(RUN_ERROR_CODES.WORLD_CONTRACT_ERROR);
});

it('classifies string throw as USER_ERROR', () => {
expect(classifyRunError('string error')).toBe(RUN_ERROR_CODES.USER_ERROR);
});
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/classify-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,15 @@ import {
StepNotRegisteredError,
WorkflowNotRegisteredError,
WorkflowRuntimeError,
WorkflowWorldError,
} from '@workflow/errors';

const WORLD_CONTRACT_ERROR_CODES = new Set([
'PARSE_ERROR',
'SCHEMA_VALIDATION',
RUN_ERROR_CODES.WORLD_CONTRACT_ERROR,
]);

/**
* Set of error names that should classify as generic `RUNTIME_ERROR`. Each
* `*.is()` static does a name-based duck check, so subclassing alone is
Expand Down Expand Up @@ -36,11 +43,32 @@ const RUNTIME_ERROR_CHECKS = [
* thrown inside the workflow VM and we'd misclassify genuine runtime
* errors as user errors.
*/
export function isWorldContractError(err: unknown): err is WorkflowWorldError {
if (!WorkflowWorldError.is(err) || err.status !== undefined) {
return false;
}

const cause = 'cause' in err ? err.cause : undefined;
return (
(err.code !== undefined && WORLD_CONTRACT_ERROR_CODES.has(err.code)) ||
err.message.startsWith('Failed to parse response body for ') ||
err.message.startsWith('Schema validation failed for ') ||
(typeof cause === 'object' &&
cause !== null &&
'name' in cause &&
cause.name === 'ZodError')
);
}

export function classifyRunError(err: unknown): RunErrorCode {
if (CorruptedEventLogError.is(err)) {
return RUN_ERROR_CODES.CORRUPTED_EVENT_LOG;
}

if (isWorldContractError(err)) {
return RUN_ERROR_CODES.WORLD_CONTRACT_ERROR;
}

for (const isMatch of RUNTIME_ERROR_CHECKS) {
if (isMatch(err)) {
return RUN_ERROR_CODES.RUNTIME_ERROR;
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/describe-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@ describe('describeError', () => {
expect(result.hint).toContain('max-delivery budget');
});

test('WORLD_CONTRACT_ERROR via precomputed errorCode is attributed to the SDK', () => {
const result = describeError(
undefined,
RUN_ERROR_CODES.WORLD_CONTRACT_ERROR
);
expect(result.attribution).toBe('sdk');
expect(result.errorCode).toBe(RUN_ERROR_CODES.WORLD_CONTRACT_ERROR);
expect(result.hint).toContain('SDK contract');
});

test('precomputed errorCode wins over classifyRunError when both are provided', () => {
// A plain Error would classify as USER_ERROR, but passing REPLAY_TIMEOUT
// explicitly overrides that — useful for callers that know the failure
Expand Down Expand Up @@ -179,6 +189,14 @@ describe('describeRunError', () => {
expect(result.hint).toContain('max-delivery budget');
});

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

test('RUNTIME_ERROR code without errorName still lands as SDK', () => {
const result = describeRunError({
errorCode: RUN_ERROR_CODES.RUNTIME_ERROR,
Expand Down Expand Up @@ -288,4 +306,16 @@ describe('describeError — payload shape snapshots', () => {
}
`);
});

test('WORLD_CONTRACT_ERROR via precomputed errorCode payload', () => {
expect(
describeError(undefined, RUN_ERROR_CODES.WORLD_CONTRACT_ERROR)
).toMatchInlineSnapshot(`
{
"attribution": "sdk",
"errorCode": "WORLD_CONTRACT_ERROR",
"hint": "The workflow backend returned data that violated the SDK contract. This is not retryable; please report it with the stack trace and runId.",
}
`);
});
});
17 changes: 17 additions & 0 deletions packages/core/src/describe-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ 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.';
const WORLD_CONTRACT_HINT =
'The workflow backend returned data that violated the SDK contract. This is not retryable; please report it with the stack trace and runId.';

function normalizeErrorCode(code: string | undefined): RunErrorCode {
// Values read back from persisted events are `string | undefined` — we
Expand Down Expand Up @@ -135,6 +137,13 @@ export function describeRunError(
hint: CORRUPTED_EVENT_LOG_HINT,
};
}
if (errorCode === RUN_ERROR_CODES.WORLD_CONTRACT_ERROR) {
return {
attribution: 'sdk',
errorCode,
hint: WORLD_CONTRACT_HINT,
};
}
if (name === 'WorkflowRuntimeError' || name === 'StepNotRegisteredError') {
return { attribution: 'sdk', errorCode, hint: RUNTIME_ERROR_HINT };
}
Expand Down Expand Up @@ -227,5 +236,13 @@ export function describeError(
};
}

if (effectiveCode === RUN_ERROR_CODES.WORLD_CONTRACT_ERROR) {
return {
attribution: 'sdk',
errorCode: effectiveCode,
hint: WORLD_CONTRACT_HINT,
};
}

return { attribution: 'user', errorCode: effectiveCode };
}
Loading
Loading