Error Handling
This page is the single place that explains what happens when something goes wrong in a workflow: which exception you see, what the engine records, when the workflow stops for good, and when it stops running without ending and how it resumes.
Overview
The engine distinguishes 3 outcomes:
| Outcome | What it means | How it ends |
|---|---|---|
Step failed |
One step did not complete. The step records |
You catch the |
Workflow terminal |
The workflow itself ended with |
|
Workflow stopped, not ended |
The body stopped without a terminal status. The stored status is still |
The engine re-drives the workflow on the next restart or segment claim, see When the workflow stops without ending. |
|
There is no |
Step failures
Every step failure surfaces as a StepFailedException or one of its subtypes. Catch the parent to handle
any abnormal step end, or a subtype to handle one case.
| Exception | When it is thrown | Step status recorded |
|---|---|---|
|
The step action threw, or the retry policy was exhausted. Also the parent of every row below. |
|
|
The step did not complete within its timeout. |
|
|
The step was cancelled through |
|
|
The action was in flight when the node crashed. The engine does not re-run it, so the outcome is unknown. Without a retry policy the step is |
|
Blocking primitives (awaitExecute, awaitEvent, awaitPublish) throw the exception. Non-blocking primitives
(execute, waitForEvent, publish) return a WorkflowStepResult; read failure(), timeout(),
canceled() or error() on it.
See Crash-interrupted steps for the at-most-once rule
behind StepIndeterminateException, and Retries for retry policies.
Catching a step failure
With a blocking step, catch and compensate:
try {
ctx.awaitExecute("chargePayment",
payload("customerId", customerId, "amount", amount).getValues(),
PaymentService::charge);
} catch (StepFailedException e) {
ctx.awaitExecute("releaseStock",
payload("customerId", customerId).getValues(),
InventoryService::releaseStock);
ctx.fail(new RuntimeException("Payment failed: " + e.getMessage())); (1)
}
| 1 | Decide the outcome yourself. Without fail, the workflow continues to the next step. |
With a non-blocking step, check the result:
var shipping = ctx.execute("shipOrder", ctx.workflowPayload(),
ShippingService::shipOrder,
step -> step.timeout(Duration.ofMinutes(5)));
if (shipping.failure()) { // blocks until the step reaches a terminal status
ctx.awaitExecute("refundPayment",
payload("customerId", customerId).getValues(),
PaymentService::refund);
ctx.fail(new RuntimeException("Shipping failed: " + shipping.error().get().getMessage()));
}
The saga pattern in Common Patterns shows compensation in reverse order.
What is stored about an error
The engine stores every step and workflow failure in the event store, so the error survives a restart and is identical during live execution and after replay. Only 3 fields are kept per exception in the cause chain:
-
the fully qualified class name of the exception
-
its message, truncated to 1023 characters
-
its cause, up to 10 levels deep
The stack trace is not stored. It is written to the application log at the moment of failure. Keeping it out of the event store keeps payloads small and avoids class-compatibility problems across JVM versions.
On replay the engine rebuilds the exception as a WorkflowExecutionException that carries the original
class name in type(). The engine’s own StepIndeterminateException is rebuilt as its own type.
Because the rebuilt cause is never the original class, instanceof checks against your exception types do
not match. Branch on the type name instead:
} catch (StepFailedException e) {
var cause = (WorkflowExecutionException) e.getCause();
if (cause.isType(OutOfStockException.class)) {
// ...
}
}
When the workflow stops for good
A workflow reaches a terminal status in these cases. A terminal event is stored, running steps are interrupted, and the instance is removed from the engine.
| Cause | Status | Notes |
|---|---|---|
|
|
Explicit. The exception is stored as described above. |
|
|
Explicit, graceful stop. |
Workflow timeout |
|
Configured on the workflow. |
A |
|
The step outcome is durable and business-visible, so the failure ends the workflow. The exception is stored as the cause. |
Any other exception the body did not catch and that is not recoverable |
|
A defect in the body, such as a |
Lifecycle listeners such as @OnFailure fire on each terminal status. See
Workflow Lifecycle.
When the workflow stops without ending
In these cases the body stops but no terminal event is stored. The status stays STARTED, the instance
stays registered on the node with its driver stopped, and events addressed to it still update its state.
Nothing marks it as stopped except the log line written at that moment.
| Cause | Why it does not fail | Log line to alert on |
|---|---|---|
Engine shutdown |
The node stops, not the workflow. Interrupted steps are not cancelled and resume on the next start. |
|
Replay drift |
The code no longer matches the recorded history. Fix the code or add |
|
The event store did not accept one of the workflow’s own events |
Infrastructure did not answer or rejected the append. The next run appends again. |
|
A recoverable exception the body did not catch |
A later run may succeed. See Recoverable versus unrecoverable exceptions. |
|
How a stopped workflow resumes
Nothing is needed beyond a restart. The engine rehydrates every non-terminal instance from the event store on 2 occasions:
-
the processing node starts
-
the node claims the workflow’s segment, for example after a rebalance
The body runs again from its recorded state. Completed steps are not re-executed. A workflow stopped by a replay drift resumes as soon as fixed code is deployed and the node restarts.
To find such workflows, query WorkflowManager for instances in STARTED whose last step event is older than
you expect, and correlate with the log lines above.
Recoverable versus unrecoverable exceptions
An exception that escapes the body and is not a StepFailedException is classified by a
RecoverableWorkflowExceptionPolicy. The default, RecoverableWorkflowExceptionPolicy.DEFAULT, walks the cause chain and
treats these as recoverable:
-
any
Error, such asOutOfMemoryErrororStackOverflowError -
InterruptedExceptionandStepInterruptedException, raised when the engine stops -
RejectedExecutionException, raised when an executor is shutting down -
AxonTransientException, the framework’s marker for a condition worth retrying -
TimeoutExceptionandIOException, raised when infrastructure did not answer
Everything else fails the workflow.
Configuring the policy
You can extend the default policy or replace it. Extend it with or(…) to keep the built-in
classification and add your own exceptions:
RecoverableWorkflowExceptionPolicy policy =
RecoverableWorkflowExceptionPolicy.DEFAULT.or(e -> e instanceof BackendUnavailableException);
Replace it with your own lambda to take full control. Only the exceptions your policy accepts then pause the workflow:
RecoverableWorkflowExceptionPolicy policy = e -> e instanceof BackendUnavailableException;
The engine picks the policy for a workflow in this order:
-
the policy set on the workflow through
.customized(…) -
the
RecoverableWorkflowExceptionPolicycomponent registered in the configuration -
RecoverableWorkflowExceptionPolicy.DEFAULT
To apply a policy to every workflow, register it as a component:
configurer.componentRegistry(registry -> registry.registerComponent(
RecoverableWorkflowExceptionPolicy.class,
c -> RecoverableWorkflowExceptionPolicy.DEFAULT.or(e -> e instanceof BackendUnavailableException)
));
In Spring Boot, declare a bean of the same type:
@Bean
public RecoverableWorkflowExceptionPolicy recoverableExceptionPolicy() {
return RecoverableWorkflowExceptionPolicy.DEFAULT.or(e -> e instanceof BackendUnavailableException);
}
To apply a policy to a single workflow, set it in .customized(…):
.customized((c, w) -> w
.recoverableExceptionPolicy(
RecoverableWorkflowExceptionPolicy.DEFAULT.or(e -> e instanceof BackendUnavailableException)
)
)
|
Prefer catching exceptions in the body and calling |