Summary
Sibling of #6. That issue reports NonRetryableError losing its class across the runner stub, so the engine retries. This one reports that the engine's message-based escape hatch — which does survive the stub — does not make the instance settle either.
The engine's own check is name-or-message, not instanceof (miniflare dist/src/workers/workflows/binding.worker.js):
err.name === 'NonRetryableError' || err.message.startsWith('NonRetryableError')
Since #6 establishes that the name is dropped crossing the stub while the message survives, prefixing the message looks like a valid workaround from tenant code that cannot import cloudflare:workflows. It isn't.
Version: @cloudflare/dynamic-workflows@0.1.1, wrangler@4.120.1, local wrangler dev.
Repro
Same dispatcher wiring as #6 — createDynamicWorkflowEntrypoint, DynamicWorkflowBinding re-exported, instances via wrapWorkflowBinding(metadata), tenant module through env.LOADER.get(id, …) with globalOutbound: null. The tenant throws inside step.do, with both signals set and without importing cloudflare:workflows:
export default {
async run(gateway, event, step) {
return step.do('denied_step', async () => {
const fatal = new Error('NonRetryableError: policy_violation')
fatal.name = 'NonRetryableError'
throw fatal
})
}
}
Expected
The step fails immediately; the instance reaches errored once.
Actual
The instance does not reach a terminal status. Polling instance.status() every 250ms for 10s never observes errored, complete or terminated, and the sentinel message appears several times in that window, consistent with the step still being retried.
One observation I could not fully resolve: the message comes back with the NonRetryableError: prefix stripped, which in the miniflare source only happens when PreservedNonRetryableError is constructed. That means a fatal branch is reached — but I could not determine whether it is the step-level branch or the outer one after the retry budget is exhausted, so I am not claiming the step-level check matched.
Why it matters
#6's workaround-shaped reading is "set the message prefix instead of relying on the class". If that also fails, tenant code inside a Dynamic Worker has no way at all to mark a failure terminal, and every deterministic failure — a validation error from a downstream API, for instance — costs the full retry budget. In our case a rejected write took six attempts across roughly thirty seconds to fail in a way that was decided in sixty milliseconds.
Happy to test a patch or provide a fuller harness.
Summary
Sibling of #6. That issue reports
NonRetryableErrorlosing its class across the runner stub, so the engine retries. This one reports that the engine's message-based escape hatch — which does survive the stub — does not make the instance settle either.The engine's own check is name-or-message, not
instanceof(miniflaredist/src/workers/workflows/binding.worker.js):Since #6 establishes that the name is dropped crossing the stub while the message survives, prefixing the message looks like a valid workaround from tenant code that cannot import
cloudflare:workflows. It isn't.Version:
@cloudflare/dynamic-workflows@0.1.1,wrangler@4.120.1, localwrangler dev.Repro
Same dispatcher wiring as #6 —
createDynamicWorkflowEntrypoint,DynamicWorkflowBindingre-exported, instances viawrapWorkflowBinding(metadata), tenant module throughenv.LOADER.get(id, …)withglobalOutbound: null. The tenant throws insidestep.do, with both signals set and without importingcloudflare:workflows:Expected
The step fails immediately; the instance reaches
erroredonce.Actual
The instance does not reach a terminal status. Polling
instance.status()every 250ms for 10s never observeserrored,completeorterminated, and the sentinel message appears several times in that window, consistent with the step still being retried.One observation I could not fully resolve: the message comes back with the
NonRetryableError:prefix stripped, which in the miniflare source only happens whenPreservedNonRetryableErroris constructed. That means a fatal branch is reached — but I could not determine whether it is the step-level branch or the outer one after the retry budget is exhausted, so I am not claiming the step-level check matched.Why it matters
#6's workaround-shaped reading is "set the message prefix instead of relying on the class". If that also fails, tenant code inside a Dynamic Worker has no way at all to mark a failure terminal, and every deterministic failure — a validation error from a downstream API, for instance — costs the full retry budget. In our case a rejected write took six attempts across roughly thirty seconds to fail in a way that was decided in sixty milliseconds.
Happy to test a patch or provide a fuller harness.