Skip to content

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

Description

@matt-jamie-milne

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

  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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions