Workflow Error Handling
Workflow errors in Causet fall into distinct categories. Each has different behavior and recovery paths.
Preflight Rejection
When an intent’s preflight rules fail, the intent is rejected immediately. No event is committed. No state changes. The saga does not advance.
The caller receives an error response with the code and message from the failing preflight rule:
preflight:
rules:
- name: must_be_parsing
when:
- path: /_tmp/import_saga/step
op: eq
value: 1
error:
code: WRONG_SAGA_STATE
message: "ticket import must be in parsing state to match artist"If _tmp/import_saga/step is not 1, the intent is rejected with WRONG_SAGA_STATE. The saga stays wherever it was.
Recovery: The caller must submit the correct intent for the current saga state, or submit a corrective intent to advance the saga manually.
Core Rule Failure
If a core rule throws a runtime error (e.g., type mismatch, invalid operation), the intent fails. No event is committed. No state changes.
This is distinct from preflight rejection — it’s an unexpected failure, not a business rule violation. These should be treated as bugs in the rules definition.
Recovery: Fix the rules DSL and redeploy. The intent can be retried once the rule is corrected.
Side Effect Failures
Side effects fire after the event is committed to the ledger. If an op: emit or op: submit fails:
- The originating event is already committed — it cannot be rolled back
- The infrastructure layer retries the failed side effect
- If the submitted intent’s preflight rejects, the error is not propagated back
Warning: This means a committed event and a failed downstream submit can create inconsistency between the originating entity’s state and the downstream entity’s state. Design your preflight rules to handle re-submission gracefully.
Saga Stalling
A saga stalls when its current step’s triggering event never arrives. The entity stays in the current step indefinitely — there is no built-in timeout.
Detection: Project saga state and monitor for entities stuck in non-terminal steps:
projections:
import_saga_health:
source_events:
- TICKET_PARSE_STARTED
- TICKET_ARTIST_MATCHED
- TICKET_METADATA_ENRICHED
- TICKET_IMPORT_COMPLETE
- TICKET_IMPORT_FAILED
target:
table: import_saga_health
primary_key: [import_id]
fields:
import_id: TEXT
saga_step: INT
last_updated_at: BIGINT
derive:
import_id: event.entity_id
saga_step: entity._tmp.import_saga.step
last_updated_at: event.tsSELECT import_id, saga_step, last_updated_at
FROM import_saga_health
WHERE saga_step BETWEEN 1 AND 3
AND last_updated_at < extract(epoch from (now() - interval '2 hours')) * 1000
ORDER BY last_updated_at ASC;Recovery: Submit a corrective intent that emits the expected event to advance the saga.
Failed Terminal States
Model explicit failure paths as terminal saga steps:
sagas:
ticket_import_flow:
state: ticket_import
state_path: _tmp/import_saga
steps:
- name: idle
set: { step: 0, outcome: "" }
- name: parsing
on: TICKET_PARSE_STARTED
set: { step: 1, outcome: "" }
- name: matched
on: TICKET_ARTIST_MATCHED
set: { step: 2, outcome: "" }
- name: enriched
on: TICKET_METADATA_ENRICHED
set: { step: 3, outcome: "" }
- name: complete
on: TICKET_IMPORT_COMPLETE
set: { step: 4, outcome: "success" }
end: true
- name: failed
on: TICKET_IMPORT_FAILED
set: { step: -1, outcome: "failed" }
end: trueWhen TICKET_IMPORT_FAILED fires, the saga transitions to failed with end: true. No further saga transitions occur on this entity.
Corrective Reset Action
To allow recovery from a failed saga, define a reset action that clears state and returns the entity to idle:
actions:
RESET_TICKET_IMPORT:
intent_type: RESET_TICKET_IMPORT
state: ticket_import
entity_key: import_id
preflight:
rules:
- name: must_be_failed
when:
- path: /_tmp/import_saga/step
op: eq
value: -1
error:
code: NOT_FAILED
message: "ticket import is not in failed state"
core:
rules:
- name: clear_import_fields
when: {}
then:
- op: set
path: /status
value: "idle"
- op: set
path: /raw_data
value: ""
- op: set
path: /artist_id
value: ""
events:
- type: TICKET_IMPORT_RESET
payload:
import_id: intent.import_id
reset_by: intent.operator_idTICKET_IMPORT_RESET does not map to a saga step — the saga remains at step: -1. To re-start the saga, the caller submits TICKET_PARSE_STARTED again after the reset, which advances to step: 1.
Note: The saga step is set by the
sagas:machinery when the triggering event fires. The reset action’s core rules clear domain state but do not directly overwrite_tmp/import_saga. The saga advances normally when the next saga-triggering event fires.
If you need to explicitly reset the saga step to 0, emit a custom event that your saga maps to an idle step. This requires that idle be reachable from failed, which requires modeling it as a non-terminal step:
- name: idle
on: TICKET_IMPORT_RESET
set: { step: 0, outcome: "" }Observability: Saga Step Distribution
Track the distribution of saga steps across all entities:
SELECT saga_step, COUNT(*) as entity_count
FROM import_saga_health
GROUP BY saga_step
ORDER BY saga_step;Alert when the count in a non-terminal step (1, 2, or 3) grows faster than entities reaching terminal steps (4 or -1). A widening gap indicates a systemic stall in the pipeline.
Error Handling Reference
| Failure type | Event committed? | State changed? | Recovery |
|---|---|---|---|
| Preflight rejection | No | No | Submit correct intent |
| Core rule failure | No | No | Fix rules, retry intent |
| Side effect emit failure | Yes | Yes (core already ran) | Infrastructure retry |
| Side effect submit rejected | Yes | Yes (core already ran) | Submit corrective intent |
| Saga stall (missing event) | N/A | No change | Submit corrective intent |
| Saga failed terminal | Yes | Yes | Submit reset intent |