Summary
When a Dynamic Worker's WorkflowEntrypoint throws NonRetryableError (imported from cloudflare:workflows), the error loses its class crossing the RPC stub back to the dispatcher, so the Workflows engine treats it as retryable and retries the step. The failure surfaces as a plain Error.
Version: @cloudflare/dynamic-workflows@0.1.1, wrangler@4.120.1. Observed under local wrangler dev.
Repro
Dispatcher wired the documented way — createDynamicWorkflowEntrypoint, DynamicWorkflowBinding re-exported, instances created through wrapWorkflowBinding(metadata). The tenant module loaded via env.LOADER.get(id, ...) with globalOutbound: null and a single RPC-stub binding:
import { WorkflowEntrypoint } from 'cloudflare:workers'
import { NonRetryableError } from 'cloudflare:workflows'
export class WorkflowRunner extends WorkflowEntrypoint {
async run(event, step) {
return step.do('denied_step', async () => {
throw new NonRetryableError('policy_violation', 'policy_violation')
})
}
}
Expected
The step fails immediately; the instance errors once.
Actual
The step is retried (I measured 6 attempts before the instance errored — each attempt re-entered the step and re-invoked the binding it calls). The instance's final status carries:
{ "name": "Error", "message": "policy_violation: policy_violation" }
The NonRetryableError name is gone and the constructor's (message, name) arguments appear concatenated into message, which looks like error serialization across the Workers RPC boundary rather than anything Workflows-specific.
Why it matters
NonRetryableError is the only way for tenant code to say "this cannot heal". With it unavailable, every terminal failure inside a Dynamic Workflow burns the full retry budget, and any per-attempt side effect (logging, metering, an audit record) is multiplied by the retry count.
Where it seems to originate
dispatchWorkflow ends with return runner.run(innerEvent, step) and neither it nor createDynamicWorkflowEntrypoint touches errors, so the thrown value is whatever survives the stub call. Nothing in the library appears intended to map it.
Possible resolutions
- Have
dispatchWorkflow catch errors from runner.run and rehydrate a native NonRetryableError in the dispatcher isolate (some marker would need to survive serialization).
- Expose an error-mapping hook alongside
loadRunner, so consumers can decide.
- If preserving the class is not feasible, document the limitation and the recommended workaround.
A consumer-side workaround exists — encode retryability in the message, then wrap the runner returned from loadRunner and re-throw a native NonRetryableError in the dispatcher — but it relies on tenant-supplied strings, so a library-level answer would be better.
Not verified
I could not verify whether deployed (non-local) behaviour differs; the worker in question is local-only by design. Happy to test a suggested patch.
Summary
When a Dynamic Worker's
WorkflowEntrypointthrowsNonRetryableError(imported fromcloudflare:workflows), the error loses its class crossing the RPC stub back to the dispatcher, so the Workflows engine treats it as retryable and retries the step. The failure surfaces as a plainError.Version:
@cloudflare/dynamic-workflows@0.1.1,wrangler@4.120.1. Observed under localwrangler dev.Repro
Dispatcher wired the documented way —
createDynamicWorkflowEntrypoint,DynamicWorkflowBindingre-exported, instances created throughwrapWorkflowBinding(metadata). The tenant module loaded viaenv.LOADER.get(id, ...)withglobalOutbound: nulland a single RPC-stub binding:Expected
The step fails immediately; the instance errors once.
Actual
The step is retried (I measured 6 attempts before the instance errored — each attempt re-entered the step and re-invoked the binding it calls). The instance's final status carries:
{ "name": "Error", "message": "policy_violation: policy_violation" }The
NonRetryableErrorname is gone and the constructor's(message, name)arguments appear concatenated intomessage, which looks like error serialization across the Workers RPC boundary rather than anything Workflows-specific.Why it matters
NonRetryableErroris the only way for tenant code to say "this cannot heal". With it unavailable, every terminal failure inside a Dynamic Workflow burns the full retry budget, and any per-attempt side effect (logging, metering, an audit record) is multiplied by the retry count.Where it seems to originate
dispatchWorkflowends withreturn runner.run(innerEvent, step)and neither it norcreateDynamicWorkflowEntrypointtouches errors, so the thrown value is whatever survives the stub call. Nothing in the library appears intended to map it.Possible resolutions
dispatchWorkflowcatch errors fromrunner.runand rehydrate a nativeNonRetryableErrorin the dispatcher isolate (some marker would need to survive serialization).loadRunner, so consumers can decide.A consumer-side workaround exists — encode retryability in the message, then wrap the runner returned from
loadRunnerand re-throw a nativeNonRetryableErrorin the dispatcher — but it relies on tenant-supplied strings, so a library-level answer would be better.Not verified
I could not verify whether deployed (non-local) behaviour differs; the worker in question is local-only by design. Happy to test a suggested patch.