-
Notifications
You must be signed in to change notification settings - Fork 22
fix(runtime-sdk): Make NonRetryableError raised in a workflow step non-retryable #266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
hoodmane
merged 2 commits into
cloudflare:main
from
hoodmane:hoodmane/workflow-nonretryable
Sep 18, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| // JavaScript helpers for Python Workflows | ||
|
|
||
| const NON_RETRYABLE_ERROR_NAME = "NonRetryableError"; | ||
|
|
||
| function isPythonError(e) { | ||
| return ( | ||
| e instanceof Error && | ||
| e.constructor?.name === "PythonError" && | ||
| typeof e.type === "string" | ||
| ); | ||
| } | ||
|
|
||
| // Extract the message the user passed to the Python exception from the | ||
| // traceback stored in `PythonError.message`. | ||
| // | ||
| // The last non-empty line of a formatted traceback is | ||
| // `<qualified.ExceptionType>: <message>`, or just `<qualified.ExceptionType>` | ||
| // when the exception was raised without a message. For example, given | ||
| // | ||
| // Traceback (most recent call last): | ||
| // File "/session/metadata/worker.py", line 19, in non_retryable | ||
| // raise NonRetryableError("do not retry") | ||
| // workers.workflows.NonRetryableError: do not retry | ||
| // | ||
| // this returns "do not retry", and given | ||
| // | ||
| // Traceback (most recent call last): | ||
| // File "/session/metadata/worker.py", line 34, in no_message | ||
| // raise NonRetryableError() | ||
| // workers.workflows.NonRetryableError | ||
| // | ||
| // it returns "". | ||
| function pythonExceptionMessage(e) { | ||
| const lines = e.message.split("\n").filter((line) => line.trim() !== ""); | ||
| const last = lines.at(-1) ?? ""; | ||
| const sep = last.indexOf(": "); | ||
| return sep === -1 ? "" : last.slice(sep + 2); | ||
| } | ||
|
hoodmane marked this conversation as resolved.
|
||
|
|
||
| // Wraps a Python step callback passed to `WorkflowStep.do()`. | ||
| // | ||
| // If the error thrown is a Python NonRetryable error, translate it into a JS | ||
| // error that the engine will recognize as non retryable. Leave other errors | ||
| // alone. | ||
| export function wrapWorkflowStepCallback(pyCallback) { | ||
| return async function (...args) { | ||
| try { | ||
| return await pyCallback(...args); | ||
| } catch (e) { | ||
| if (isPythonError(e) && e.type === NON_RETRYABLE_ERROR_NAME) { | ||
| const err = new Error(pythonExceptionMessage(e)); | ||
| err.name = NON_RETRYABLE_ERROR_NAME; | ||
| throw err; | ||
| } | ||
| throw e; | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| // Wraps the `WorkflowStep` RPC stub passed to a Python | ||
| // `WorkflowEntrypoint.run()` so that the callback given to `step.do()` goes | ||
| // through `wrapWorkflowStepCallback`. | ||
| // | ||
| // Because the wrapped `do` returns the stub's own promise, a Python callback | ||
| // passed to it keeps exactly the lifetime it would have had without the | ||
| // wrapper. | ||
| export function wrapWorkflowStep(step) { | ||
| // RPC stubs are callable, so `typeof step` is 'function'. | ||
| if ( | ||
| step === null || | ||
| (typeof step !== "object" && typeof step !== "function") | ||
| ) { | ||
| return step; | ||
| } | ||
| return new Proxy(step, { | ||
| apply(target, thisArg, args) { | ||
| return Reflect.apply(target, thisArg, args); | ||
| }, | ||
| get(target, prop) { | ||
| if (prop !== "do") { | ||
| return Reflect.get(target, prop); | ||
| } | ||
| return function (name, ...rest) { | ||
| const args = rest.map((arg) => | ||
| typeof arg === "function" ? wrapWorkflowStepCallback(arg) : arg | ||
| ); | ||
| return target.do(name, ...args); | ||
| }; | ||
| }, | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
5 changes: 5 additions & 0 deletions
5
packages/runtime-sdk/tests/workerd-test/workflow/pyproject.toml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| [project] | ||
| name = "test" | ||
| version = "0.0.0" | ||
| requires-python = ">=3.12" | ||
| dependencies = [] |
112 changes: 112 additions & 0 deletions
112
packages/runtime-sdk/tests/workerd-test/workflow/worker.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| // Copyright (c) 2026 Cloudflare, Inc. | ||
| // Licensed under the Apache 2.0 license found in the LICENSE file or at: | ||
| // https://opensource.org/licenses/Apache-2.0 | ||
| import { RpcTarget } from 'cloudflare:workers'; | ||
|
|
||
| import * as assert from 'node:assert'; | ||
|
|
||
| // A stand-in for the Workflows engine's `WorkflowStep`. Like the real engine it runs the step | ||
| // callback over RPC, and it records the error it receives when the callback fails so the test | ||
| // can assert on what the engine would have seen. | ||
| class Context extends RpcTarget { | ||
| constructor() { | ||
| super(); | ||
| this.errors = []; | ||
| this.sleeps = []; | ||
| } | ||
|
|
||
| async do(name, ...rest) { | ||
| // do(name, callback) or do(name, config, callback) | ||
| const callback = rest.at(-1); | ||
| try { | ||
| return await callback({ | ||
| step: { name, count: 1 }, | ||
| attempt: 1, | ||
| config: {}, | ||
| }); | ||
| } catch (e) { | ||
| this.errors.push({ step: name, name: e.name, message: e.message }); | ||
| // The engine rethrows so the workflow's `run()` can handle the error. | ||
| throw e; | ||
| } | ||
| } | ||
|
|
||
| async sleep(name, duration) { | ||
| this.sleeps.push({ name, duration }); | ||
| } | ||
| } | ||
|
|
||
| // The engine treats a step error as non-retryable when | ||
| // `error.name === 'NonRetryableError' || error.message.startsWith('NonRetryableError')` | ||
| // (the name is folded into the message when the error is tunneled over RPC without enhanced | ||
| // error serialization). | ||
| function isNonRetryable(e) { | ||
| return ( | ||
| e.name === 'NonRetryableError' || e.message.startsWith('NonRetryableError') | ||
| ); | ||
| } | ||
|
|
||
| function nonRetryableMessage(e) { | ||
| return e.name === 'NonRetryableError' | ||
| ? e.message | ||
| : e.message.replace(/^NonRetryableError(: )?/, ''); | ||
| } | ||
|
|
||
| export default { | ||
| async test(ctrl, env) { | ||
| const step = new Context(); | ||
| const result = await env.PythonWorkflow.run( | ||
| { payload: { foo: 'bar' } }, | ||
| step | ||
| ); | ||
|
|
||
| // --- What the engine saw ------------------------------------------------------------- | ||
| const byStep = Object.fromEntries(step.errors.map((e) => [e.step, e])); | ||
| assert.deepStrictEqual(Object.keys(byStep).sort(), [ | ||
| 'non_retryable', | ||
| 'non_retryable_no_message', | ||
| 'type_error', | ||
| ]); | ||
|
|
||
| // A Python NonRetryableError must arrive as a non-retryable JS error carrying the | ||
| // Python message, not as a generic PythonError with a traceback. | ||
| assert.ok(isNonRetryable(byStep.non_retryable), byStep.non_retryable); | ||
| assert.strictEqual(nonRetryableMessage(byStep.non_retryable), 'do not retry'); | ||
| assert.ok( | ||
| isNonRetryable(byStep.non_retryable_no_message), | ||
| byStep.non_retryable_no_message | ||
| ); | ||
| assert.strictEqual( | ||
| nonRetryableMessage(byStep.non_retryable_no_message), | ||
| '' | ||
| ); | ||
|
|
||
| // Other Python exceptions are left alone: they arrive as Pyodide's PythonError with the | ||
| // traceback as the message. Depending on the compatibility date the error is serialized | ||
| // either as `{name: 'PythonError', message: '<traceback>'}` (enhanced error | ||
| // serialization) or as `{name: 'Error', message: 'PythonError: <traceback>'}`. | ||
| assert.ok(!isNonRetryable(byStep.type_error), byStep.type_error); | ||
| assert.match( | ||
| `${byStep.type_error.name}: ${byStep.type_error.message}`, | ||
| /PythonError: Traceback/ | ||
| ); | ||
| assert.match(byStep.type_error.message, /TypeError: intentional type error/); | ||
|
|
||
| // --- What the Python workflow saw once the engine rethrew ---------------------------- | ||
| assert.deepStrictEqual(result.non_retryable, { | ||
| caught: 'NonRetryableError', | ||
| message: 'do not retry', | ||
| }); | ||
| assert.deepStrictEqual(result.non_retryable_no_message, { | ||
| caught: 'NonRetryableError', | ||
| message: '', | ||
| }); | ||
| assert.strictEqual(result.type_error.caught, 'TypeError'); | ||
| assert.match(result.type_error.message, /intentional type error/); | ||
|
|
||
| // The wrapped `step` still behaves like the real one otherwise. | ||
| assert.deepStrictEqual(result.ok, { attempt: 1, payload: { foo: 'bar' } }); | ||
| assert.strictEqual(result.slept, true); | ||
| assert.deepStrictEqual(step.sleeps, [{ name: 'nap', duration: '1 second' }]); | ||
| }, | ||
| }; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.