Error Handling
Dataflow-rs provides structured error handling across tasks, workflows, and runs.
Two Error Channels: message.errors() and Run::status()
Every run reports errors through two complementary places:
message.errors()— Contains everyErrorRecordrecorded during the run: failedvalidationrules,5xxstatus outcomes,Errreturns from host functions, and errors explicitly recorded viaCtx::record_error. Each record includescode,message,workflow_id,task_id, andtimestamp.Run::status()— Describes how the overall run ended:Completed: All matching workflows finished with no error records on the message.CompletedWithErrors: All matching workflows finished, and one or more errors were recorded inmessage.errors().Stopped(error): A task failed and its failure policy stopped the run before all workflows finished.TimedOut/Cancelled: The run hit itsRunOptions::deadlineorCancelToken.
When a run stops early, no duplicate wrapper error is added to message.errors()—the failing task's own ErrorRecord is on the message, and RunStatus::Stopped(error) carries the stopping Error.
#![allow(unused)] fn main() { use dataflow_rs::{Engine, Message}; async fn _demo(engine: Engine, mut message: Message) { use dataflow_rs::run::RunStatus; let run = engine.run(&mut message).await; for error in message.errors() { println!( "{} in {}/{}: {}", error.code, error.workflow_id.as_deref().unwrap_or("-"), error.task_id.as_deref().unwrap_or("-"), error.message ); } // Check if an unhandled failure stopped the run early: if let RunStatus::Stopped(error) = run.status() { eprintln!("stopped early: {error} ({})", error.code()); } // Or convert fatal stops (`Stopped`, `TimedOut`, `Cancelled`) into `Result::Err`: let run = run.into_result(); let _ = run; } }
run.into_result() returns Ok(run) for Completed and CompletedWithErrors, and Err(error) when the run was Stopped, TimedOut, or Cancelled.
Failures vs. Rejections
Before any policy is applied, the engine classifies each task's return value:
| Task Return Value | Classification | Error Recorded on message.errors() |
|---|---|---|
Ok(Outcome::Ok) or status < 400 | Success | None |
Ok(Outcome::Status(4xx)) | Rejection | None by the engine (validation records its own failing rules before returning 400) |
Ok(Outcome::Status(5xx)) | Failure | TASK_STATUS_ERROR: Task <id> returned status <s> |
Err(error) | Failure | error.code(): Task <id> error: <message> |
Ok(Outcome::Skip) | Skipped | None (and no audit entry is written) |
Ok(Outcome::Halt) | Halted | None (ends the current workflow with status 299) |
Tasks configure how rejections and failures are handled using on_rejection and on_failure, while workflows configure what happens when a task failure escalates to the workflow using workflow-level on_failure.
Task-Level Policies
on_failure
Controls what happens when a task returns Err or Outcome::Status(5xx):
{
"id": "optional_task",
"on_failure": "continue",
"function": { ... }
}
"fail"(default) — Stops the current workflow and escalates the failure to the workflow'son_failurepolicy."continue"— Records the failure inmessage.errors()and continues to the next task in the workflow."halt"— Records the failure and ends the current workflow (WorkflowExit::Halted), while allowing subsequent workflows to run.
on_rejection
Controls what happens when a task returns Outcome::Status(4xx):
{
"id": "validate_input",
"on_rejection": "halt",
"function": { "name": "validation", "input": { ... } }
}
"continue"(default) — Continues to the next task in the workflow."halt"— Ends the current workflow immediately (WorkflowExit::Halted), while allowing subsequent workflows to run.
Note: A
4xxrejection from a custom host function (such asOutcome::Status(404)) records status404on the audit trail without adding an entry tomessage.errors(). The built-invalidationfunction both records aVALIDATION_ERRORper failing rule onmessage.errors()and returns400.
Workflow-Level Policy
A workflow's on_failure field answers a different question than a task's on_failure field—it is not a default inherited by tasks:
| Policy Field | Question It Answers |
|---|---|
on_failure on a task | When this task fails, should the remaining tasks in this workflow still run? |
on_failure on a workflow | When a task failure stops this workflow, should subsequent workflows still run, or should the entire run stop? |
(Task groups only share a condition; they do not have their own failure policy.)
The Policy Matrix
When a task fails (returns Err or a 5xx status), the combination of task on_failure and workflow on_failure determines what runs next:
Task on_failure | Workflow on_failure | Remaining Tasks in Workflow | Subsequent Workflows | Final Run::status() |
|---|---|---|---|---|
"fail" (default) | "fail" (default) | Stop | Stop | Stopped(error) |
"fail" | "continue" | Stop | Run | CompletedWithErrors (workflow exit is Failed) |
"halt" | (either) | Stop | Run | CompletedWithErrors (workflow exit is Halted) |
"continue" | (either) | Run | Run | CompletedWithErrors |
In every case above, the failure is recorded in message.errors().
For example, if a workflow sets "on_failure": "continue" while its tasks keep the default "on_failure": "fail", a failure in task1 stops that workflow immediately, but subsequent workflows in the engine still run:
{
"id": "resilient_workflow",
"on_failure": "continue",
"tasks": [
{"id": "task1", "on_failure": "continue", "function": { ... }},
{"id": "task2", "on_failure": "continue", "function": { ... }},
{"id": "task3", "function": { ... }}
]
}
Here, task1 and task2 explicitly opt into "on_failure": "continue", so the workflow keeps running even if they fail. task3 uses the default "fail", so if task3 fails, resilient_workflow exits with Failed and the engine moves on to the next workflow.
Policy Matrix Example in Rust
use dataflow_rs::run::{RunStatus, WorkflowExit}; use dataflow_rs::{Ctx, Engine, Error, Function, Message, Outcome, Result}; use serde_json::json; /// A custom function that always fails with a service error code. struct Flaky; impl Function for Flaky { type Config = (); async fn call(&self, _ctx: &mut Ctx<'_>, _cfg: &()) -> Result<Outcome> { Err(Error::service("PARTNER_DOWN", "the partner is down").with_retryable(true)) } } #[tokio::main] async fn main() { let engine = |on_failure: &str| { Engine::builder() .register("flaky", Flaky) .workflow_json(&json!({"id": "w", "tasks": [ {"id": "call", "on_failure": on_failure, "function": {"name": "flaky"}}, {"id": "after", "function": {"name": "map", "input": { "mappings": [{"path": "data.after", "logic": true}]}}} ]})) .workflow_json(&json!({"id": "next", "priority": 1, "tasks": [ {"id": "t", "function": {"name": "map", "input": { "mappings": [{"path": "data.next", "logic": true}]}}} ]})) .build() .expect("the workflows build") }; // 1. Default ("fail"): stops the entire run. Neither `after` nor `next` runs. let mut message = Message::from_json(&json!({})); let run = engine("fail").run(&mut message).await; let RunStatus::Stopped(error) = run.status() else { panic!("stopped") }; assert_eq!(error.code(), "PARTNER_DOWN"); assert!(run.retryable() && run.partial()); assert_eq!(message.errors()[0].code, "PARTNER_DOWN"); assert_eq!(message.errors()[0].message, "Task call error: the partner is down"); assert_eq!(message.errors()[0].task_id.as_deref(), Some("call")); assert!(message.context().get(&"data.next".parse().unwrap()).is_none()); // 2. "continue": records the error and runs both `after` and `next`. let mut message = Message::from_json(&json!({})); let run = engine("continue").run(&mut message).await; assert!(matches!(run.status(), RunStatus::CompletedWithErrors)); assert_eq!(message.errors().len(), 1); assert_eq!(serde_json::to_string(message.context().data()).unwrap(), r#"{"after":true,"next":true}"#); // 3. "halt": records the error, ends workflow `w` (skipping `after`), and runs `next`. let mut message = Message::from_json(&json!({})); let run = engine("halt").run(&mut message).await; assert_eq!(run.workflows().next().unwrap().exit, Some(WorkflowExit::Halted)); assert_eq!(serde_json::to_string(message.context().data()).unwrap(), r#"{"next":true}"#); }
Error Codes
Common error codes recorded in message.errors():
VALIDATION_ERROR— Recorded byvalidationfor each failing rule, and byparse_xml,publish_json, andpublish_xmlwhen input data is missing or invalid.EVALUATION_ERROR— Recorded when avalidationrule orfiltercondition fails to evaluate.MAPPING_ERROR— Recorded for each individualmapmapping that fails (with the mapping's target path inrecord.path), followed byTASK_STATUS_ERRORfor the task.TASK_STATUS_ERROR— Recorded when a task returnsOutcome::Status(s)withs >= 500.
When a custom host function returns Err(error), the record uses error.code():
- The custom code passed to
Error::service(code, message)(or"TASK_ERROR"ifcodeis empty). - Built-in engine error codes:
LOGIC_ERROR(ErrorKind::Eval),BUDGET_EXCEEDED(Budget),TIMEOUT_ERROR(TimedOut),CANCELLED,CONFIG_ERROR(Config), andCOMPILATION_ERROR(Compile).
Because custom functions can define their own service error codes, always include a wildcard/default arm when matching on error.code.
Custom Service Errors (Error::service)
Built-in ErrorKind variants cover engine-level errors. When a custom host function fails for a domain- or service-specific reason (such as an open circuit breaker, upstream 503, or rate limit), construct an error with Error::service:
#![allow(unused)] fn main() { use dataflow_rs::Error; use dataflow_rs::ErrorKind; let error = Error::service("circuit_open", "upstream unavailable") .with_retryable(true) .with_source(std::io::Error::other("connection refused")); assert_eq!(error.code(), "circuit_open"); assert_eq!(error.to_string(), "upstream unavailable"); assert!(error.is_retryable()); assert!(matches!(error.kind(), ErrorKind::Service { .. })); }
Key behaviors:
- Exact code preservation: The engine keeps your
codestring as-is (without altering case) inErrorRecord::codeandErrorContext. An empty string falls back to"TASK_ERROR". - Explicit retryability: Only engine timeouts default to
retryable = true. For service errors, call.with_retryable(true)when the caller can safely retry the run (Run::retryable()reflects the stopping error's flag). - Error chaining: Use
.with_source(err)to attach an underlyingstd::error::Errorcause, and.wrap("context")to prefix the message while preserving the error kind, code, retryability, and source.
Branching on Task Failures (ErrorContext)
By default, message.errors() is only accessible from Rust after (or outside) JSONLogic evaluation, because JSONLogic expressions only see {data, metadata, temp_data}.
If you want subsequent tasks or workflows to branch on why an earlier task failed, configure an ErrorContext path on EngineConfig:
use dataflow_rs::run::{EngineConfig, ErrorContext}; use dataflow_rs::Engine; fn main() -> dataflow_rs::Result<()> { let config = EngineConfig::default() .error_context(ErrorContext::new("metadata.errors".parse()?, 32)?); let builder = Engine::builder().config(config); let _ = builder; Ok(()) }
When ErrorContext is configured, each error recorded by a task is mirrored as a compact object at the configured path:
{ "workflow_id": "place_order", "task_id": "charge_payment",
"code": "TIMEOUT_ERROR", "status": 500 }
Subsequent task or workflow conditions can then inspect the error code directly:
{ "in": [ { "var": "metadata.errors.0.code" }, ["TIMEOUT_ERROR", "IO_ERROR"] ] }
End-to-End Example
use dataflow_rs::run::{EngineConfig, ErrorContext}; use dataflow_rs::{Engine, Message}; use serde_json::json; #[tokio::main] async fn main() -> dataflow_rs::Result<()> { let engine = Engine::builder() .config(EngineConfig::default() .error_context(ErrorContext::new("metadata.errors".parse()?, 32)?)) .workflow_json(&json!({"id": "w", "tasks": [ {"id": "check", "function": {"name": "validation", "input": {"rules": [ {"logic": {"!!": {"var": "data.email"}}, "message": "Email is required"} ]}}}, {"id": "route", "condition": {"in": [{"var": "metadata.errors.0.code"}, ["VALIDATION_ERROR"]]}, "function": {"name": "map", "input": { "mappings": [{"path": "data.routed", "logic": "rejected"}]}}} ]})) .build() .expect("the workflow builds"); let mut message = Message::from_json(&json!({})); engine.run(&mut message).await; let records = message.context().get(&"metadata.errors".parse()?).expect("one record"); assert_eq!( serde_json::to_string(records).unwrap(), r#"[{"workflow_id":"w","task_id":"check","code":"VALIDATION_ERROR","status":400}]"# ); let routed = message.context().get(&"data.routed".parse()?).expect("routed"); assert_eq!(serde_json::to_string(routed).unwrap(), r#""rejected""#); Ok(()) }
ErrorContext Details and Rules
- Mirrored events: Mirrors the exact same events as
message.errors()(Errreturns,5xxoutcomes, failedvalidationrules, andCtx::record_errorcalls). - Omitted message text: Mirrored records include
{workflow_id, task_id, code, status}but intentionally omit the free-text errormessageso internal details are not exposed in serialized message contexts. Read full error messages frommessage.errors()in Rust. - Reset at run start: The engine clears the configured path at the start of every run. If no errors occur during a run, the path remains absent rather than
[]. - Bounded capacity: Retains up to
limitnewest records (ErrorContext::DEFAULT_LIMITis32; minimum is1), preventing unbounded growth in loops. - Reserved path validation:
ErrorContext::newrejects bare roots (data,metadata,temp_data) and reservedmetadatakeys (progress,processed_at,engine_version,channel). - Audit vs. Trace: Mirrored error-context writes do not clutter task
AuditEntry::changes, but are recorded inTraceStep::changesso trace replay accurately reconstructsmetadata.errors.
Error Behavior by Task Type
Validation Errors
Generated by the validation built-in when rules do not evaluate to true:
{
"function": {
"name": "validation",
"input": {
"rules": [
{
"logic": {"!!": {"var": "data.email"}},
"message": "Email is required"
}
]
}
}
}
A rule passes only when logic evaluates to boolean true. The message expression is evaluated only when the rule fails. If logic itself fails to evaluate, EVALUATION_ERROR is recorded instead.
Expression Evaluation Errors
log: If amessageorfieldsexpression fails to evaluate,"<eval error>"is logged and the task still succeeds with status200.map: If a mapping fails,MAPPING_ERRORis recorded onmessage.errors()(withpathset to the mapping's target), remaining mappings still execute, and the task finishes with status500(governed byon_failure).filter: If the condition fails to evaluate,EVALUATION_ERRORis recorded and the filter treats the condition as rejected (299underhalt, orSkipunderskip).- Operation budget (
ops_budget): Exceeding the configured operation budget invalidation,map, orfilterfails the task withBUDGET_EXCEEDEDunderon_failure. - Workflow & task conditions: If a workflow or task
conditionfails to evaluate, it is treated asfalse(and recorded onRunasSelection::ConditionFailed(error)for workflows).
Custom Host Function Errors
Return Err(...) for failures or Ok(Outcome::Status(4xx)) for rejections:
#![allow(unused)] fn main() { use dataflow_rs::{Ctx, Error, Function, Outcome, Result}; struct Charge; impl Function for Charge { type Config = (); async fn call(&self, ctx: &mut Ctx<'_>, _cfg: &()) -> Result<Outcome> { let amount = ctx.get(&"data.amount".parse()?).and_then(|v| v.as_f64()); let Some(amount) = amount else { return Err(Error::service("MISSING_AMOUNT", "data.amount is not a number")); }; if amount > 10_000.0 { // Return a 4xx rejection (governed by `on_rejection`). return Ok(Outcome::Status(422)); } Ok(Outcome::Ok) } } }
When a host function returns Err, all context writes made during that call are discarded, while any error records added via Ctx::record_error are kept alongside the returned Err.
Common Error Recovery Patterns
Fallback Values
Combine "on_failure": "continue" with a conditional fallback task:
{
"tasks": [
{
"id": "try_primary",
"on_failure": "continue",
"function": {
"name": "fetch_primary",
"input": { "output": "temp_data.result" }
}
},
{
"id": "use_fallback",
"condition": {"!": {"var": "temp_data.result"}},
"function": {
"name": "map",
"input": {
"mappings": [
{"path": "data.result", "logic": "default_value"}
]
}
}
}
]
}
Because a failed host function call discards its writes, temp_data.result remains absent when try_primary fails, causing use_fallback to run.
Halting on Failed Validation
Set "on_rejection": "halt" on a validation task to prevent subsequent tasks in the workflow from running on invalid data:
{
"tasks": [
{
"id": "validate_input",
"on_rejection": "halt",
"function": {
"name": "validation",
"input": {
"rules": [
{"logic": {"!!": {"var": "data.required_field"}}, "message": "Required field missing"}
]
}
}
},
{
"id": "process",
"function": { ... }
}
]
}
Try It
Open the Full Debugger UI for step-by-step execution and workflow visualization.
Notice how validate_email records a VALIDATION_ERROR, and greet still runs because on_rejection defaults to "continue".
Best Practices
- Validate Early and Set
on_rejection: "halt"— Place validation tasks at the beginning of a workflow and set"on_rejection": "halt"whenever invalid data should block downstream tasks. - Use
on_failure: "continue"Selectively — Reserve"continue"for optional enrichment or non-critical side effects. - Check Both
Run::status()andmessage.errors()— InspectRun::status()to see if execution stopped early, and iterate overmessage.errors()to see all recorded validation and task errors.
Migrating from 3.x
In 3.x, process_message returned Result<(), DataflowError>, stopping runs added an extra WORKFLOW_ERROR wrapper record to message.errors, and error policies were configured with continue_on_error and halt_on.
In 4.0:
Engine::runalways returns aRun(userun.status()orrun.into_result()).- No duplicate
WORKFLOW_ERRORrecord is added when a run stops. - Policies use
on_failureandon_rejection(3.x keys are automatically rewritten by the compatibility reader). - Errors use
ErrorandErrorKindinstead ofDataflowError.
See Migrating from 3.x for full details.