Error Handling
Dataflow-rs provides flexible error handling at multiple levels to build resilient automation rules.
Two complementary error channels
Every error encountered during process_message flows through two
complementary channels:
message.errors()— always contains every error encountered: validation failures, task panics, 5xx-status outcomes, workflow wrappers. Callers that want a uniform view scan this list.Result::Errfromprocess_message— signals only that the engine stopped before processing every workflow. Callers that want fail-fast match on it; the error pushed tomessage.errors()for the same failure carries the workflow context that the bareErrdoesn’t.
A workflow with continue_on_error: true records its errors to
message.errors() and returns Ok(()). A workflow with
continue_on_error: false records to message.errors() and returns
Result::Err (which short-circuits the rest of process_message).
Error Levels
Errors can be handled at three levels:
- Action Level - Individual action (task) error handling
- Rule Level - Rule-wide (workflow) error policy
- Engine Level - Processing errors
Action-Level Error Handling
Stop on Error (Default)
{
"id": "critical_action",
"continue_on_error": false,
"function": { ... }
}
If the action fails:
- Error is recorded in
message.errors() - Rule execution stops
- No further actions execute
Continue on Error
{
"id": "optional_action",
"continue_on_error": true,
"function": { ... }
}
If the action fails:
- Error is recorded in
message.errors() - Rule continues to next action
Rule-Level Error Handling
The two continue_on_error flags answer different questions, and the
rule-level one is not a default for its actions:
| Flag | Question it answers |
|---|---|
| on an action | When this action fails, do the remaining actions in this rule still run? |
| on a rule | When this rule fails, do the subsequent rules still run — and does process_message return Ok? |
There is no third level. A task group
carrying continue_on_error parses and does nothing — a group gates a span, it
does not handle errors — and check_workflow reports it as
GROUP_CONTINUE_ON_ERROR.
Written out as a matrix, where “action fails” means it returned an error or a
5xx status:
| action flag | rule flag | Later actions in the rule | Later rules | process_message |
|---|---|---|---|---|
false | false | stop | stop | Err |
false | true | stop | run | Ok |
true | (either) | run | run | Ok |
So a rule marked continue_on_error: true whose actions leave the flag unset
still stops at its first failing action — it just does not take the rest of the
engine down with it:
{
"id": "resilient_rule",
"continue_on_error": true,
"tasks": [
{"id": "action1", "continue_on_error": true, "function": { ... }},
{"id": "action2", "continue_on_error": true, "function": { ... }},
{"id": "action3", "function": { ... }}
]
}
Because each action opts in, the rule runs to the end even if action1 and
action2 fail; action3 leaves the flag unset, so a failure there still stops
the rule — while the rule-level flag keeps later rules running.
Mixing the two levels
{
"id": "mixed_rule",
"continue_on_error": true,
"tasks": [
{"id": "optional_action", "continue_on_error": true, "function": { ... }},
{
"id": "critical_action",
"continue_on_error": false,
"function": { ... }
}
]
}
optional_action may fail without consequence. If critical_action fails, the
rest of this rule is abandoned, but the engine moves on to the next rule and
process_message still returns Ok — the failure is reported through
message.errors() only. Set the rule’s flag to false as well to make that
failure stop the run and surface as Err.
Accessing Errors
After processing, walk message.errors():
#![allow(unused)]
fn main() {
use dataflow_rs::{Engine, Message};
async fn _demo(engine: Engine, mut message: Message) {
let result = engine.process_message(&mut message).await;
for error in message.errors() {
println!("Error: {} in {}/{}",
error.message,
error.workflow_id.as_deref().unwrap_or("unknown"),
error.task_id.as_deref().unwrap_or("unknown")
);
}
// Fail-fast signal — true when the engine stopped before all workflows ran.
if let Err(e) = result {
eprintln!("engine stopped early: {e}");
}
}
}
Common error codes you’ll see:
VALIDATION_ERROR— from thevalidationbuilt-in, or a handler returningDataflowError::ValidationTASK_ERROR— handler returnedDataflowError::TaskTASK_STATUS_ERROR— handler returnedTaskOutcome::Status(s)withs >= 500WORKFLOW_ERROR— wrapper recording workflow context for the failure above
Every other engine variant contributes its own code the same way:
FUNCTION_NOT_FOUND, FUNCTION_ERROR, LOGIC_ERROR, HTTP_ERROR,
TIMEOUT_ERROR, IO_ERROR, DESERIALIZATION_ERROR, UNKNOWN_ERROR.
Changed in 3.5.0. Before this release every variant except
Servicecollapsed toTASK_ERRORon the live path, so a timeout, a dropped connection and a rejected request were indistinguishable. If you were matching onTASK_ERRORto mean “the handler returnedErr”, match the specific codes instead — or returnDataflowError::Task, which still maps toTASK_ERROR.
That list is not closed: a handler returning a service-classified error
contributes its own code (see below). Switch on code with a default arm.
Service-classified errors
The engine’s error variants describe engine concerns. When your handler fails for a reason only your service understands — a circuit breaker opened, a tenant hit a rate limit — classify it yourself:
#![allow(unused)]
fn main() {
use dataflow_rs::DataflowError;
fn _demo() -> DataflowError {
DataflowError::service("circuit_open", "upstream unavailable")
.detail("connector 'billing' breaker open since 12:04")
.retryable(true)
.build()
}
}
Three things this buys you:
kindbecomes theErrorInfo::codeonmessage.errors(), passed through verbatim — not upper-cased — so the string you switch on is the string you wrote. An emptykindfalls back toTASK_ERROR.detailis a separate, operator-only channel.Displayrendersmessagealone, soto_string()is always safe to hand to an untrusted caller; the detail is reachable throughDebug,DataflowError::detail()andErrorInfo::detail. It is omitted from the serialized form when absent, so nothing changes for errors that do not carry one.retryableis declared, not inferred from the variant. The engine never retries a task on its own, but the flag is not inert:retry_with_policyandretry_with_attempts(added in 3.7.0) readretryable()to decide whether a failed attempt is worth repeating, so declaring it correctly is what makes those loops behave. Anywhere else, it is carriage for your own retry policy.
Everything else is unchanged: continue_on_error, the audit-trail entry, and the
Result::Err short-circuit behave exactly as for any other error. The
WORKFLOW_ERROR wrapper still records workflow context and keeps its own code, so
counting errors by code does not double-count. No built-in ever returns this
variant.
Branching on why a task failed
message.errors() is host-side only — the JSONLogic evaluation context is
exactly {data, metadata, temp_data}, so {"var": "errors"} resolves to
nothing. To let a workflow branch on why a step failed, point the engine at a
context path:
#![allow(unused)]
fn main() {
use dataflow_rs::{Engine, Workflow};
fn _demo(workflows: Vec<Workflow>) -> dataflow_rs::Result<()> {
let engine = Engine::builder()
.with_workflows(workflows)
.with_error_context_path("metadata.errors")
.build()?;
Ok(()) }
}
Off unless called. With no path configured nothing is written, and the whole
mechanism is one Option check on a path that only runs after a task has
already failed.
One record is appended per error a task contributes:
{ "workflow_id": "place_order", "task_id": "charge_payment",
"code": "TIMEOUT_ERROR", "status": 500 }
so a later task — or a later workflow — can route on the reason:
{ "in": [ { "var": "metadata.errors.0.code" }, ["TIMEOUT_ERROR", "IO_ERROR"] ] }
What is recorded
Coverage matches errors(): a handler returning Err, a task returning a 5xx
outcome, each failing rule of the validation built-in, and anything a handler
adds through TaskContext::add_error. Two deliberate exclusions:
- The
WORKFLOW_ERRORwrapper. It re-reports the same underlying failure, so mirroring it would double-count. A task failure withcontinue_on_error: falsetherefore puts two entries onmessage.errors()but one record here. - Tasks returning
TaskOutcome::Skip. Skip opts out of the per-task record entirely — no audit entry, nometadata.progresswrite, no record.
status is the task’s own status: 500 when the handler returned Err,
otherwise the status the outcome carried (400 for validation, 200 for a
handler that recorded an error and still succeeded). That is the distinction
metadata.progress cannot make — its failure arm hard-codes 500.
The error message and the operator-only detail are not recorded. This
value lands in Message.context, which is serialized straight back to callers;
read those from message.errors() host-side instead. Note this applies to
temp_data too — it is part of context and ships on the wire like everything
else, so it is not private scratch space.
Practical notes
- The key is absent, not
[], when nothing failed — a clean message keeps the exact wire shape it had before the option existed. - At most 32 records are kept by default, newest retained; change it with
.with_error_context_limit(n). The bound is what keeps the cost independent of a looping workflow’s iteration count, sinceMessage.contextis deep-cloned into every trace snapshot. - The engine owns the configured path. A non-array found there is replaced.
metadata.progressis rejected atbuild(), as is any path that does not start withdata,metadataortemp_data— such a path would write somewhere the evaluation context cannot see, giving you a condition that is silently never true. - Prefer
metadata.*ortemp_data.*overdata.*: the first append into adata.*path costs a one-time re-arena of the wholedatasubtree, which is the heavy payload side. - The append is engine bookkeeping, not a task mutation, so it is not recorded as
an audit-trail
Change.
Error Types
Validation Errors
Generated by the validation function when rules fail:
{
"function": {
"name": "validation",
"input": {
"rules": [
{
"logic": {"!!": {"var": "data.email"}},
"message": "Email is required"
}
]
}
}
}
Execution Errors
Generated when function execution fails:
- JSONLogic evaluation errors
- Data type mismatches
- Missing required fields
Custom Function Errors
Return errors from custom functions via Result::Err:
use dataflow_rs::prelude::*;
impl AsyncFunctionHandler for MyFunction {
type Input = serde_json::Value;
async fn execute(
&self,
ctx: &mut TaskContext<'_>,
_input: &serde_json::Value,
) -> Result<TaskOutcome> {
if some_condition {
return Err(DataflowError::Task(
"Custom error message".to_string()
));
}
Ok(TaskOutcome::Success)
}
}
DataflowError provides typed variants for the most common cases —
Validation, Task, Workflow, FunctionExecution, FunctionNotFound,
Http, Timeout, Io, LogicEvaluation, Deserialization, Unknown.
See the API reference for the full list.
Error Recovery Patterns
Fallback Values
Use conditions to provide fallback values:
{
"tasks": [
{
"id": "try_primary",
"name": "Try primary",
"continue_on_error": true,
"function": {
"name": "map",
"input": {
"mappings": [
{"path": "temp_data.result", "logic": {"var": "data.primary"}}
]
}
}
},
{
"id": "use_fallback",
"name": "Use fallback",
"condition": {"!": {"var": "temp_data.result"}},
"function": {
"name": "map",
"input": {
"mappings": [
{"path": "data.result", "logic": "default_value"}
]
}
}
}
]
}
Validation Before Processing
Validate data before critical operations:
{
"tasks": [
{
"id": "validate",
"function": {
"name": "validation",
"input": {
"rules": [
{"logic": {"!!": {"var": "data.required_field"}}, "message": "Required field missing"}
]
}
}
},
{
"id": "process",
"function": { ... }
}
]
}
If validation fails, the rule stops before further processing.
Try It
Want more features? Try the Full Debugger UI with step-by-step execution and workflow visualization.
Notice the validation error is recorded but processing continues.
Best Practices
-
Validate Early
- Add validation actions at the start of rules
- Fail fast on invalid data
-
Use continue_on_error Wisely
- Only for truly optional actions
- Critical operations should stop on error
-
Check Errors
- Always check
message.errors()after processing - Log errors for monitoring
- Always check
-
Provide Context
- Include meaningful error messages
- Include field paths in validation errors