Packages

Declarative workflow orchestration for the Ash Framework — multi-step workflows combining human actions, background jobs, and time-based deadlines.

Current section

Files

Jump to
ash_workflow documentation topics error-handling.md
Raw

documentation/topics/error-handling.md

# Error Handling

AshWorkflow handles errors through the `on_error` option on automatic steps, and relies on AshStateMachine to reject transitions the workflow never declared. This guide covers what happens when a step fails and how to design workflows around failure.

## Automatic step failures

When an automatic step's action fails — it raises, or a change adds a changeset error — Ash rolls the action back. The `on_success` transition never happens, so the record is still in the step when the job ends.

What happens next depends on whether the step declares `on_error`.

### With `on_error`

`AshWorkflow.Transformers.AddActions` generates a hidden update action named `__on_error_<step>` and `AshWorkflow.Transformers.AddScheduler` wires it to the generated trigger's `on_error`. That action transitions the record to the error state and records the event.

```elixir
workflow do
  step :process, action: :do_processing, on_success: :done, on_error: :failed
  step :done, terminal: true
  step :failed, terminal: true
end
```

When `:do_processing` fails:

1. Oban records the failed attempt. `max_attempts` comes from the step's `retry` block and defaults to `1`, so without one the first failure is also the last attempt.
2. Once the last attempt has failed, AshOban calls `__on_error_process`, which transitions the record from `:process` to `:failed`.
3. `AshWorkflow.Changes.RecordEvent` writes a transition log row for that transition with `triggered_by: :error_path`, and `AshWorkflow.Telemetry` emits the state change.
4. Oban marks the job completed rather than discarded, because AshOban's `on_error_fails_job?` defaults to `false`.

The record does not stay in `:process` waiting for an operator. It moves to `:failed`, and the trigger's filter stops matching it. Read the failure back off the transition log rather than off the job table.

### Without `on_error`

A step with no `on_error` has no error action for the trigger to call. The job fails, the record stays in the step, and the trigger matches it again on the next scheduler cycle. The action runs again, and keeps running on every cycle until it succeeds or someone moves the record by hand. Declare `on_error` on every automatic step whose action can fail.

## Retry

A step or a timeout can declare a `retry` block, the failure policy for its generated work:

```elixir
step :process do
  action :do_processing
  on_success :done
  on_error :failed

  retry do
    max_attempts 3
    backoff {10, :seconds}
  end
end
```

- `max_attempts` defaults to `1`. A step with no `retry` block gets that default, so its `on_error` moves it to the error state on the very first failure. The example above raises that to three attempts before `:process` moves to `:failed`.
- `backoff` is a duration tuple, such as `{10, :seconds}`, for a fixed delay between attempts, or `:exponential` to grow the delay with the attempt number. It has no effect while `max_attempts` is `1`.

A timeout can declare its own `retry` block, with the same options and the same defaults:

```elixir
timeout :reminder do
  fire_after {3, :days}
  action :send_reminder

  retry do
    max_attempts 3
  end
end
```

`AshWorkflow.Scheduler.Oban` and `AshWorkflow.Scheduler.Precise` honour `retry` through different mechanisms but the same meaning. Oban turns `max_attempts` and `backoff` into the generated trigger's own options and lets Oban's own retry loop run the attempts. Precise re-arms its timer for the backoff delay after a failed attempt, under the same key the original deadline used. Either way, `on_error` runs only after the final attempt has failed. It never runs on an attempt a retry will follow.

A `retry` block is rejected at compile time on a manual step, a wait state, or a terminal step, since none of them has a generated trigger for it to apply to.

## Designing for failure

### Make step actions idempotent

A step's action can run more than once: on a repeat cycle when the step has no `on_error`, and on every attempt the step's `retry` block allows. Actions should be safe to run twice. If a step sends an email, record a flag on the record and check it, rather than sending again.

### Use error states for investigation

Rather than making an error state terminal, make it a manual step with a transition back into the workflow:

```elixir
workflow do
  step :process, action: :do_processing, on_success: :done, on_error: :needs_review

  step :needs_review do
    transition :retry, to: :process
    transition :abandon, to: :abandoned
  end

  step :done, terminal: true
  step :abandoned, terminal: true
end
```

A record that fails `:do_processing` lands in `:needs_review` on the first failure, with an `:error_path` row naming the transition. An operator lists records in that state, reads the row, and either retries or abandons.

## Manual transition failures

Manual transitions fail if the state machine rejects them — for example, calling `:approve` on a record that's already in `:approved`. The error includes a `NoMatchingTransition` message with the current state and attempted target.

## Conditional route failures

If a conditional transition has no matching route for the current record, the action fails with a descriptive error. If a route's `when` expression fails to evaluate (e.g., references a missing field), the error includes the specific expression that failed and the underlying reason.

## Compensation and rollback

AshWorkflow does not provide automatic compensation or rollback. Each transition is an Ash action, and Ash handles transactional semantics at the action level — if a change within an action fails, the entire action is rolled back.

If your workflow creates side effects (external API calls, related records) that need cleanup on failure, handle this in your action's error paths or in a dedicated error-handling step.