Skip to main content
Version: v5.6.1

4.3 Error semantics

Included from Basic tier

After this you can: predict what a workflow does on each error path — continue, stop, compensate — and prove it by running every path yourself.

Before you start — tick each one:

  • Lesson 4.2 — Store, browse, run (opschain learn 4.2)

In the GUI: the workflow run page shows each step's outcome, and the chains screen walks the cascade — its Triage view classifies a failing chain.

Error policy is worth a drill, not just a read — each path behaves differently and you will design against all of them. This lesson is a lab: three tiny actions make failure deterministic, one small workflow per path shows what the run does, and the chain shows what the record says about it. Run every path; the outcome is that none of them can surprise you later.

1. The vocabulary​

Failures in a workflow have the same vocabulary as YAML actions (lesson 2.1):

  • A change/workflow step may declare retries: {attempts, backoff} — each automatic retry spawns a fresh nested run, with the attempt history on the step. Retries exhaust before any error policy applies.
  • A step whose when: calls error() or always() runs on the failure path (compensation, cleanup) while the workflow run still finishes error, recording error_handled. success() and steps.<name>.status read the scope's state here too — when: error() && steps.patch.status == 'error' targets the compensation at the step that actually failed.
  • A change step may declare its error policy inline with on_error:. The string form continue lets the sweep proceed past that step's failure (one bad node never stops the batch; the step still records error honestly). The object form names a compensating action outright:
- type: change
name: patch_infrastructure
target: ${{ '/projects/' + inputs.project + '/assets/infrastructure' }}
action: infrastructure:patch
retries: { attempts: 2, backoff: 30s }
on_error:
uses: infrastructure:rollback
with:
snapshot: ${{ steps.patch_infrastructure.outputs.snapshot_id }}
reason: ${{ error.message }}
then: fail # fail (default) | continue

The object form is sugar for a hand-written failure-path step: it desugars into a sibling <step>.on_error change gated by when: error('<step>'), with the failed step's recorded error bound as error.step/error.status/error.message. then: fail keeps the run finishing error (compensation never masks the failure), then: continue lets the workflow proceed once the handler succeeded, and a handler that itself fails is terminal. opschain docs expressions has the full semantics. Now prove each of those sentences.

2. Three tiny actions​

In your repository from lesson 2.1, add .opschain/actions/demo/fail.yaml:

action: fail
description: Always fails - the trigger for every on_error path
steps:
- name: blow_up
run: |
echo "about to fail deliberately"
exit 1

a demo/ok.yaml that just echoes (proof the run continued):

action: ok
description: Always succeeds - proof that the run continued
steps:
- name: proof
run: echo "the run continued past the failure"

and a demo/cleanup.yaml handler that prints what it was handed:

action: cleanup
description: Compensating handler - prints the error context it was handed
inputs:
failed_step: { type: string }
message: { type: string, default: '' }
steps:
- name: compensate
run: |
echo "compensation running for step: ${{ inputs.failed_step }}"
echo "error message: ${{ inputs.message }}"

Because demo/ sits under .opschain/actions/, demo:fail resolves anywhere this repository's actions do: locally with --env dev, and on the server for the environment target whose project carries this repository as its git remote (lesson 3.1), or any asset templated from it. Each path below is one local workflow in .opschain/workflows/; swap the target default for your own environment path.

3. Path 1 — on_error: continue​

# on_error_string_continue.yaml
workflow: on_error_string_continue
name: on_error - string continue
inputs:
target: { type: string, default: /projects/acme/environments/dev }
steps:
- type: change
name: failing-step
action: demo:fail
target: ${{ inputs.target }}
on_error: continue
- type: change
name: proof-of-continuation
action: demo:ok
target: ${{ inputs.target }}
opschain runs create workflow on_error_string_continue --env dev --local
2026-10-12T12:02:10Z failing-step pending -> queued
2026-10-12T12:02:10Z failing-step queued -> running
[failing-step/demo:fail/blow_up] about to fail deliberately
2026-10-12T12:02:11Z failing-step running -> error
2026-10-12T12:02:11Z proof-of-continuation pending -> queued
2026-10-12T12:02:11Z proof-of-continuation queued -> running
[proof-of-continuation/demo:ok/proof] the run continued past the failure
2026-10-12T12:02:12Z proof-of-continuation running -> success

run 01a14300-…-example success (1.9s)

failing-step records error, proof-of-continuation still runs, the run completes — exit code 0. One bad element never stops the sweep, and nothing pretends the step succeeded.

Now the same on the server, where the record lives:

opschain workflows import acme -f .opschain/workflows/on_error_string_continue.yaml
opschain runs create workflow acme/on_error_string_continue --follow
created workflow acme/on_error_string_continue (version 1)
created workflow 01a14310-…-example (on_error_string_continue on acme) — pending
run 01a14310-…-example pending
run 01a14310-…-example pending → running
run 01a14310-…-example running → success

What the chain shows. A workflow run you create directly is its own chain root, so its id is the correlation id:

opschain chains show 01a14310-…-example
on_error_string_continue · initiated by alice (user) · started 2026-10-12 12:05:40Z · 1 runs · 2 changes · 14 events · 8s
+0s on_error_string_continue @ /projects/acme success · 8s 01a14310
+0s ├─ failing-step: demo:fail @ /projects/acme/environments/dev error · 3s 01a14311
+4s └─ proof-of-continuation: demo:ok @ /projects/acme/environments/dev success · 3s 01a14312

Two changes, one error and one success, under a root that finished success. chains show --errors-only narrows to the failing change — the record keeps the failure even though the policy absorbed it.

4. Path 2 — compensate, then fail​

Same shape, but the failing step carries the object form with the default then: fail:

# on_error_compensate_fail.yaml
workflow: on_error_compensate_fail
name: on_error - compensate then fail
inputs:
target: { type: string, default: /projects/acme/environments/dev }
steps:
- type: change
name: failing-step
action: demo:fail
target: ${{ inputs.target }}
on_error:
uses: demo:cleanup
with:
failed_step: '${{ error.step }}'
message: '${{ error.message }}'
then: fail
- type: change
name: proof-of-continuation
action: demo:ok
target: ${{ inputs.target }}
opschain runs create workflow on_error_compensate_fail --env dev --local
2026-10-12T12:10:02Z failing-step running -> error
2026-10-12T12:10:02Z failing-step.on_error pending -> queued
2026-10-12T12:10:02Z failing-step.on_error queued -> running
[failing-step.on_error/demo:cleanup/compensate] compensation running for step: failing-step
[failing-step.on_error/demo:cleanup/compensate] error message: exit status 1
2026-10-12T12:10:03Z failing-step.on_error running -> success

run 01a14320-…-example error (1.7s)

A failing-step.on_error step appears and prints the failed step's name and message (error.* bound in handler scope), error_handled is recorded — and the trailing step never runs, the run finishes error, exit code 1. Compensation ran; the failure still surfaced.

What the chain shows. Import and run it on the server as in path 1, then:

opschain chains show <run-id>
on_error_compensate_fail · initiated by alice (user) · started 2026-10-12 12:12:30Z · 1 runs · 2 changes · 15 events · 7s
+0s on_error_compensate_fail @ /projects/acme error · 7s 01a14330
+0s ├─ failing-step: demo:fail @ /projects/acme/environments/dev error · 3s 01a14331
+4s └─ failing-step.on_error: demo:cleanup @ /projects/acme/environments/dev success · 3s 01a14332

Two changes again — but the second is the handler, not the proof step, and the root finished error. chains triage <run-id> lists the failure with its class (error, the step's exit status) and log tail, and the handler as a ~ caveat: a step that ran as an error handler.

5. Path 3 — compensate, then continue​

Change one word: then: continue. Run it locally:

2026-10-12T12:15:44Z failing-step running -> error
2026-10-12T12:15:45Z failing-step.on_error running -> success
2026-10-12T12:15:46Z proof-of-continuation running -> success

run 01a14340-…-example success (2.4s)

The handler runs, then the trailing step, and the run completes — ignore-after-handler-success.

What the chain shows. Three changes under a success root: the failed step, the handler, the proof step. chains triage renders it "green, with caveats": one ~ line for the step that ran as an error handler. A green chain that absorbed trouble is never silently green (lesson 5.1).

6. Path 4 — compensation on another node​

The handler may name its own target:, so compensation can run on a different node than the one that failed (notify a control asset, clean up a shared resource) — error.* still describes the step that failed:

on_error:
uses: demo:cleanup
target: /projects/acme/environments/dev/assets/db
with:
failed_step: '${{ error.step }}'
message: '${{ error.message }}'
then: continue

What the chain shows. The same three changes as path 3, with the handler's row at the other node's path — the chain is the only place the hand-off between the two nodes reads as one story.

7. Two more facts to prove​

  • Make the handler itself fail: add an explode boolean input to demo:cleanup that exits 1, pass explode: true in the with:, and the run finishes error regardless of then: — a failing handler is terminal. On the chain, both the step and its handler show error.
  • Try declaring on_error: on a handler — handlers never nest; the import refuses it by name before anything is stored.
  • Read why a step failed without opening the server log: opschain runs show <run-id> prints the recorded reason on the line under the failed step (↳ …), the step resource carries it as last_error_message, and the GUI's step tree shows it on the node. A change step whose nested change was refused before it started — its action's YAML file fails validation, its with: names an input the action does not declare, a required input is missing — ends in that instant with the API's own refusal as the reason. A broken action file is also visible before any run: the template version lists it under invalid_action_files, and its resolution emits warn:template:action_file_invalid once (opschain events --type-search action_file_invalid).

Stage-wide compensation (one cleanup for a whole group rather than per step) remains the hand-written when: error() step from section 1.

Check yourself​

  • Without re-running: for each of the four paths, what is the workflow run's final status, how many changes does chains show list under it, and which path produces a chain chains triage calls "green, with caveats"?

What you learned​

  • on_error: continue absorbs a step's failure for the sweep's sake; the step still records error and the chain still shows it.
  • The object form runs a compensating action with error.step and error.message bound; then: fail (the default) keeps the run error and skips the rest, then: continue carries on once the handler succeeded.
  • A handler may target another node; a handler that fails is terminal; handlers never nest; retries exhaust before any of this applies.
  • The chain is where the policy's choices become visible: chains show lists every change the run made with its real status, and chains triage names the failure or the caveats.

Next: lesson 4.4 — Gates, policies and retirement (opschain learn 4.4), or back to your track (opschain learn).