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.
workflow do
step :process, action: :do_processing, on_success: :done, on_error: :failed
step :done, terminal: true
step :failed, terminal: true
endWhen :do_processing fails:
- Oban records the failed attempt.
max_attemptscomes from the step'sretryblock and defaults to1, so without one the first failure is also the last attempt. - Once the last attempt has failed, AshOban calls
__on_error_process, which transitions the record from:processto:failed. AshWorkflow.Changes.RecordEventwrites a transition log row for that transition withtriggered_by: :error_path, andAshWorkflow.Telemetryemits the state change.- Oban marks the job completed rather than discarded, because AshOban's
on_error_fails_job?defaults tofalse.
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:
step :process do
action :do_processing
on_success :done
on_error :failed
retry do
max_attempts 3
backoff {10, :seconds}
end
endmax_attemptsdefaults to1. A step with noretryblock gets that default, so itson_errormoves it to the error state on the very first failure. The example above raises that to three attempts before:processmoves to:failed.backoffis a duration tuple, such as{10, :seconds}, for a fixed delay between attempts, or:exponentialto grow the delay with the attempt number. It has no effect whilemax_attemptsis1.
A timeout can declare its own retry block, with the same options and the same defaults:
timeout :reminder do
fire_after {3, :days}
action :send_reminder
retry do
max_attempts 3
end
endAshWorkflow.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:
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
endA 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.