Validating & Handling Errors
Check a workflow is wired correctly, and handle what orchestrate() returns.
See Workflows for defining states and building a workflow. This page covers validation and runtime error handling.
Workflow Validation
Before orchestrating a workflow, you can validate its configuration to catch common mistakes early. Cano provides two validation methods that check for different categories of problems.
The gate every orchestrate() call passes through
validate()
Checks the overall workflow structure. Returns CanoError::Configuration if problems are found.
Checks performed
No handlers registered — the workflow has no states mapped to tasks.
No exit states defined — the workflow has no way to terminate.
validate_initial_state()
Checks that a specific initial state has a handler registered. Returns CanoError::Configuration
if the given state has no registered task or split handler.
use cano::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum State { Start, Process, Complete }
#[derive(Clone)]
struct MyTask;
#[derive(Clone)]
struct ProcessTask;
#[task(state = State)]
impl MyTask {
async fn run_bare(&self) -> Result<TaskResult<State>, CanoError> {
Ok(TaskResult::Single(State::Process))
}
}
#[task(state = State)]
impl ProcessTask {
async fn run_bare(&self) -> Result<TaskResult<State>, CanoError> {
Ok(TaskResult::Single(State::Complete))
}
}
#[tokio::main]
async fn main() -> Result<(), CanoError> {
let store = MemoryStore::new();
let workflow = Workflow::new(Resources::new().insert("store", store.clone()))
.register(State::Start, MyTask)
.register(State::Process, ProcessTask)
.add_exit_state(State::Complete);
// Validate structure: ensures handlers and exit states exist
workflow.validate()?;
// Validate that the initial state has a handler
workflow.validate_initial_state(&State::Start)?;
// Safe to orchestrate
let _result = workflow.orchestrate(State::Start, CancellationToken::disabled()).await?;
Ok(())
}
Runnable example: cargo run --example workflow_validation — a well-formed workflow that
passes, plus the failure cases (a missing transition target, an unregistered initial state) and the
exact errors validate() / validate_initial_state() return.
Error Handling
The orchestrate() method can return several error variants depending on what goes wrong
during execution. Understanding these errors helps you build robust error recovery logic.
Where each runtime error comes from
| Error Variant | Condition | How to Fix |
|---|---|---|
CanoError::Workflow |
No handler registered for current state | Register a task for every reachable state with register() |
CanoError::Workflow |
Single task returned TaskResult::Split |
Use register_split() instead of register() for parallel tasks |
CanoError::WorkflowTimeout |
Wall-clock budget set via Workflow::with_total_timeout() elapsed; in-flight task aborted, compensation stack drained. Surfaced under CanoError::WithStateContext. |
Increase with_total_timeout() or speed up the workflow; see Resilience → Workflow Total Timeout |
CanoError::Cancelled |
Run cancelled via a live CancellationToken passed to orchestrate / resume_from; in-flight task aborted, compensation stack drained. Surfaced under CanoError::WithStateContext (or CompensationFailed on a dirty rollback). |
Expected when you cancel deliberately; see Resilience → Cooperative Cancellation |
CanoError::Configuration |
PartialTimeout strategy used without timeout configured |
Add .with_timeout(duration) to JoinConfig |
CanoError::Timeout |
Per-attempt timeout from TaskConfig::attempt_timeout elapsed |
Increase with_attempt_timeout() or speed up the task; combine with a RetryMode if transient |
CanoError::RetryExhausted |
All retry attempts exhausted by a Task | Increase retry count or fix the underlying transient failure |
CanoError::CircuitOpen |
Call rejected by an open CircuitBreaker attached to TaskConfig |
Wait for the breaker's reset_timeout or fix the upstream dependency; the retry loop short-circuits — no attempts are consumed |
CanoError::TaskExecution |
Single task panicked (message is prefixed with "panic:") |
Inspect the panic payload in the message; fix the underlying invariant in the task body |
CanoError::* |
Any error propagated from task execution | Check the specific task logic — TaskExecution, Store, etc. |
Single-task execution is wrapped in catch_unwind: a panicking task surfaces as
CanoError::TaskExecution("panic: …") rather than aborting the workflow. Split tasks are
already isolated by tokio::task::JoinSet, so panics there propagate as task failures
through the join strategy.
match workflow.orchestrate(State::Start, CancellationToken::disabled()).await {
Ok(final_state) => println!("Completed: {:?}", final_state),
Err(CanoError::Workflow(msg)) => eprintln!("Workflow error: {}", msg),
Err(CanoError::Configuration(msg)) => eprintln!("Config error: {}", msg),
Err(CanoError::Timeout(msg)) => eprintln!("Attempt timed out: {}", msg),
Err(CanoError::RetryExhausted(msg)) => eprintln!("Retries exhausted: {}", msg),
Err(e) => eprintln!("Task error: {}", e),
}