Error Handling
Control what happens when a workflow step fails: stop the run, continue past it, or retry
Every workflow step decides what happens when it fails. By default a failure stops the whole run — but you can also continue past it or retry.
The simplest failing pipeline: on_error="stop" (the Task default) aborts the run the moment a step raises.
from praisonaiagents import Task, AgentFlow, WorkflowContext, StepResult
def boom(ctx: WorkflowContext) -> StepResult:
raise RuntimeError("boom")
def after(ctx: WorkflowContext) -> StepResult:
return StepResult(output="this never runs")
workflow = AgentFlow(steps=[
Task(name="boom", handler=boom, max_retries=0),
Task(name="after", handler=after),
])
result = workflow.start("go")
print(result["status"]) # "failed" — after() never ranHow it Works
A step raises, on_error is consulted, and the run either aborts or keeps going — but the final status reflects the failure either way.
Which mode when?
Pick the mode that matches how much a single failure should affect the run.
- Stop → data integrity matters; one failure means the whole run is wrong.
- Continue → batch processing; partial results are useful.
- Retry → transient failures like network blips or rate limits.
on_error Options
| Value | Behavior |
|---|---|
"stop" (default) | Failing step aborts the whole workflow, status="failed". Downstream steps do not run. Exception is not fed to the next step. |
"continue" | Failing step is recorded as failed; the workflow keeps going. Final status="failed" if any step failed. Exception is not fed to the next step. |
"retry" | Same as stop, but only after max_retries are exhausted. |
Retry Configuration
Set max_retries on flaky steps. Retries use exponential backoff of 2 ** (attempt - 1) seconds between attempts.
from praisonaiagents import Task, AgentFlow
class TransientError(Exception):
is_retryable = True # honoured by the retry machinery
flow = AgentFlow(steps=[
Task(name="fetch", handler=fetch_data, max_retries=3, on_error="retry"),
])
flow.start("go")max_retries— how many extra attempts before the step is considered failed (default3).is_retryable— setFalseon your exception class to short-circuit retries for known non-retryable failures.- Backoff between attempts is
2 ** (attempt - 1)seconds.
Guardrail Failures
A guardrail that never validates after exhausting its retries is treated like an exception — it surfaces as step_error and follows the same on_error rules. See Guardrails for validator configuration.
Errors in Nested Patterns
As of PR #4194, the same rules apply inside every nested pattern. A stopping step propagates out of the pattern and aborts the enclosing workflow.
In loop
Sequential and parallel iterations both honour on_error. A stopping iteration halts the enclosing workflow, not just the loop.
from praisonaiagents import Task, AgentFlow, WorkflowContext, StepResult, loop
def boom(ctx: WorkflowContext) -> StepResult:
raise RuntimeError("boom")
def after(ctx: WorkflowContext) -> StepResult:
return StepResult(output="this never runs")
workflow = AgentFlow(
steps=[
loop(steps=[Task(name="boom", handler=boom, max_retries=0)], over="items"),
Task(name="after", handler=after),
],
variables={"items": ["only"]},
)
result = workflow.start("go")
print(result["status"]) # "failed"
print(any(s["step"] == "after" for s in result["steps"])) # FalseIn parallel
The three on_failure modes now behave differently (see the table below).
from praisonaiagents import Task, AgentFlow, WorkflowContext, StepResult, parallel, loop
def boom(ctx: WorkflowContext) -> StepResult:
raise RuntimeError("boom")
workflow = AgentFlow(steps=[
parallel(
[loop(steps=[Task(name="boom", handler=boom, max_retries=0)], over="items")],
on_failure="fail_fast",
)
], variables={"items": ["only"]})
result = workflow.start("go")
print(result["status"]) # "failed"In when / if_
A stopping step inside a conditional branch stops the workflow, not just the branch.
In route
A stopping step inside the matched route stops the workflow.
In repeat
A stopping iteration ends the repeat loop and the workflow.
In Discussion
A speaker with on_error="stop" halts the entire discussion, and the stop propagates to the enclosing workflow — later steps do not run.
on_error="continue" keeps running
from praisonaiagents import Task, AgentFlow, WorkflowContext, StepResult, loop
def boom(ctx: WorkflowContext) -> StepResult:
raise RuntimeError("boom")
workflow = AgentFlow(
steps=[loop(
steps=[Task(name="boom", handler=boom, on_error="continue", max_retries=0)],
over="items",
)],
variables={"items": ["a", "b", "c"]},
)
result = workflow.start("go")
print(result["status"]) # "failed" — the run keeps going but reports failureThe exception text is never fed to the next step. A downstream step receives the last successful output (or the initial input), never "Error: boom".
Reading the Result
Inspect result["status"] and each step’s error field to find what failed.
result = workflow.start("go")
if result["status"] == "failed":
for step in result["steps"]:
if step.get("error"):
print(f"{step['step']} failed: {step['error']}")You can also read workflow.step_statuses for a per-step status map after the run.
parallel(on_failure=...) Modes
| Mode | When any branch fails | Variables merged from failed branch |
|---|---|---|
fail_fast | Raise WorkflowStepError as soon as any branch fails (completion order). Remaining branches cancelled where possible; the caller is not blocked on uninterruptible threads. See #5718. | None — nothing from any branch is merged |
fail_all | Let all branches finish, then raise with collected errors. | None — nothing from any branch is merged |
partial_ok | Record the failure, keep the other branches, return combined output. | Any variables the failed branch wrote before it failed (its own output_variable is never set, since the failure returns before that write) |
timeout=N (orthogonal to on_failure) | Raise WorkflowStepError when the wall-clock bound elapses, even if no branch has failed yet. | None — the raise happens before the merge runs |
For the full merge rule (declaration order, collision warning, delta-only merge), see Named outputs from branches.
In every mode the whole run is marked failed. With partial_ok, the failing branch is recorded as {"output": None, "status": "failed", "error": str(e)} — the exception text is not folded into the branch output.
As of PraisonAI #4203, Parallel propagates a nested on_error="stop" → halts the workflow (matches Route / Loop / Repeat / If), even under partial_ok. The None recorded for the failed branch stays index-aligned with its step, so parallel_outputs[i] is always the result of parallel_step.steps[i]. See Stop propagation from nested steps.
Behavior Change
Best Practices
Default to stop
Use on_error="stop" unless you have a concrete reason to continue. It keeps a failed run from producing partially-correct output.
Handle per-item errors inside loops
Prefer per-item try/except inside your handler when you want fine-grained control inside loop(parallel=True) — convert an expected failure into a valid StepResult the rest of the run can consume.
Retry flaky steps, don't loop them
Set max_retries on steps that hit the network or LLM rate limits rather than wrapping them in repeat.
Check status before reusing output
Never feed a previous step’s output to a downstream step without checking result["status"] first.