16. Catching action errors: on error

Actions can raise. By default an unhandled exception fails the execution terminally — the runner sets status=FAILED and dead-letters it. on error lets the machine route an exception to a recovery state instead, keeping the execution alive and giving the model a chance to handle it explicitly.

The problem: an exception kills the execution

Consider a payment step that calls a card processor. If the processor client raises, the whole execution is marked FAILED — even if the failure was expected and recoverable.

machine payment {
  initial Charging
  state Charging { on enter charge_card }
  final Done    success {}
  final Aborted failed  {}

  from Charging to Done on Charged
}

A ValueError inside charge_card ends the execution with no verdict and no way to distinguish a card-decline from a bug.

Fixing it with on error

Add a transition from Charging to Declined on error. The engine synthesises an error event the moment the action raises and routes it immediately — the same way any other event triggers a transition.

machine payment {
  initial Charging
  state Charging { on enter charge_card }
  state Declined { on enter notify_declined }
  final Done    success {}
  final Aborted failed  {}

  from Charging to Declined on error
  from Charging to Done     on Charged
  from Declined to Aborted
}

If charge_card raises, the machine moves to Declined where notify_declined runs, then settles in Aborted. The execution reaches a clean terminal state with a verdict.

Running it

from harel import definition_from_dsl, DurableRunner, DictStore, Event

SOURCE = """
machine payment {
  initial Charging
  state Charging { on enter charge_card }
  state Declined { on enter notify_declined }
  final Done    success {}
  final Aborted failed  {}

  from Charging to Declined on error
  from Charging to Done     on Charged
  from Declined to Aborted
}
"""


def charge_card(stm, event, **inputs):
    raise ValueError("card declined by processor")


def notify_declined(stm, event, **inputs):
    stm.execution_ctx["decline_reason"] = stm.execution_ctx["_error"]["message"]


defn = definition_from_dsl(
    SOURCE,
    "payment",
    actions={"charge_card": charge_card, "notify_declined": notify_declined},
)
runner = DurableRunner(DictStore(), {defn.id: defn})

exe = runner.create(defn.id)
print("path:   ", exe.active_path)       # Aborted
print("outcome:", exe.outcome)            # failed
print("reason: ", exe.context["decline_reason"])
print("_error: ", exe.context["_error"])
path:    Aborted
outcome: failed
reason:  card declined by processor
_error:  {'type': 'ValueError', 'message': 'card declined by processor'}

Two things the engine does automatically:

  • context["_error"] is set to {"type": "...", "message": "..."} before the error transition fires — the recovery state’s action can read it.

  • Execution status stays DONE (with a failed outcome), not FAILED. The execution reached a modelled terminal state; status=FAILED means the engine itself gave up.

Routing by exception type

on error can be guarded just like any event transition. The synthetic error event carries type (the exception class name) and message, both guardable with where.

machine payment {
  initial Charging
  state Charging { on enter charge_card }
  state Declined {}
  final Done    success {}
  final Aborted failed  {}
  final Crashed failed  {}

  from Charging to Declined on error where type == "ValueError"
  from Charging to Crashed  on error
  from Charging to Done     on Charged
  from Declined to Aborted
}

A ValueError routes to Declined; any other exception falls through to the catch-all on error (no guard). Guards are evaluated top-to-bottom in declaration order, and the first match wins — the same rules as any other guarded transition.

If no on error guard matches (including the case where every handler has a where clause that doesn’t match the raised exception), the runner’s default policy applies: the execution is failed terminally (status=FAILED), exactly as if no on error were declared at all.

Scope and inheritance

on error is a transition, not a state-level declaration. It lives wherever any transition lives — at the machine root or inside a composite state’s block — and the normal scope rules apply: a transition declared inside state Outer {} is only in scope while Outer (or one of its substates) is active.

To catch errors from a whole group of states in one place, declare the handler in a wrapping composite:

state Checkout {
  initial Charging
  state Charging { on enter charge_card }
  state Capturing { on enter capture_card }
  state CheckoutError {}

  from Charging  to Capturing    on Authorized
  from Checkout  to CheckoutError on error   # catches any action error inside Checkout
  from CheckoutError to …
}

What on error does not cover

  • Non-action code: only exceptions raised inside an action function are intercepted. Engine-internal errors (bugs in harel itself) are not routed.

  • Error handler raises too: if the action in the error-handler state also raises, the runner’s default policy applies — there is no second-level routing to avoid loops.

  • Guards and selectors: errors in guard functions or selector functions are not intercepted by on error.

  • on exit: an exception raised while leaving a state is never routed, regardless of scope. Leaving a state runs real, un-undoable side effects (releasing a lock, cancelling orthogonal regions, disarming a timer); recovering away from it would have to re-run that very on exit to reach anywhere outside the state’s own subtree, re-triggering the same failure. So on exit is expected to always succeed — a raise there always falls straight to the runner’s default policy (fail the execution), the same as an unmatched on error.

Model expected failures as action results routed by a selector; use on error for genuine exceptions you want to recover from in the model.

See also: guards, selectors, DSL reference.