cloudflare / cloudflare/dynamic-workflows

NonRetryableError thrown inside a Dynamic Worker is flattened crossing the runner stub, so the engine retries it

Open
#6 2 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
43
Forks
8
PR merge metrics
No merged PRs in 30d

Description

### 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:

```js
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:

```json
{ "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

1. Have `dispatchWorkflow` catch errors from `runner.run` and rehydrate a native `NonRetryableError` in the dispatcher isolate (some marker would need to survive serialization).
2. Expose an error-mapping hook alongside `loadRunner`, so consumers can decide.
3. 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.

Contributor guide

Open the contributing guide

Research direction

Start with dispatchWorkflow, createDynamicWorkflowEntrypoint, and loadRunner, then trace runner.run across the RPC stub under local wrangler dev using the documented reproduction. Determine how the error is serialized and choose a library-level handling path. Done means NonRetryableError reaches the dispatcher with its identity preserved, the step is attempted once, and the instance retains the expected failure status.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
backend, distributed-systems
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.