Advanced topics
Error handling and retries
Handle failures with error edges, timeout edges, and retry policies.
Error handling in Polaris separates agentic errors from infrastructure errors, giving you explicit control over how your agent recovers from each.
Two kinds of errors
Section titled “Two kinds of errors”Agentic errors are failures in your application logic: an LLM call returns an unexpected format, a tool execution fails, or a validation check rejects the output. These are the errors your systems return through Result<T, SystemError>.
Infrastructure errors are failures in parameter resolution: a required resource is missing, a borrow conflict occurs, or a type mismatch prevents a system from starting. These surface as SystemError::ParamError and typically indicate a bug in your graph wiring, not a runtime condition you should recover from.
The error handling primitives described on this page address agentic errors. Infrastructure errors propagate immediately and halt the graph unless caught by an error handler.
Fallible systems
Section titled “Fallible systems”A system that returns Result<T, SystemError> is considered fallible. The #[system] macro detects the Result return type and sets is_fallible() to true automatically.
use polaris_ai::system::prelude::SystemError;use polaris_ai::system::system;use polaris_ai::system::param::Res;use polaris_ai::models::llm::Llm;use polaris_ai::models::llm::LlmResponse;
struct AgentLlm(Llm);impl polaris_ai::system::resource::LocalResource for AgentLlm {}
#[system]async fn call_llm(llm: Res<AgentLlm>) -> Result<LlmResponse, SystemError> { llm.0.builder() .system("You are a helpful assistant.") .user("Say hello.") .generate() .await .map_err(|err| SystemError::ExecutionError(err.to_string()))}On success, the output T is stored in the context and is available to downstream systems through Out<T>. On failure, the executor checks whether the node has an error edge. If it does, execution routes to the error handler. If it does not, the error propagates up and halts the graph.
Error edges
Section titled “Error edges”An error edge connects a fallible system to a recovery subgraph. You attach one with on_error on the system builder, or with add_error_handler on the graph.
use polaris_ai::system::system;use polaris_ai::graph::Graph;
#[system]async fn call_llm() -> Result<String, polaris_ai::system::prelude::SystemError> { todo!() }#[system]async fn fallback_response() -> String { "I encountered an error.".into() }
let mut graph = Graph::new();graph.system(call_llm) .on_error(|g: &mut Graph| { g.add_system(fallback_response); }) .done();When call_llm fails, execution jumps to fallback_response instead of halting. The rest of the graph continues from the error handler’s output.
You can also attach a shared error handler to multiple nodes at once. This is useful when several fallible systems should share the same recovery logic.
use polaris_ai::system::system;use polaris_ai::graph::Graph;
#[system]async fn step_a() -> Result<i32, polaris_ai::system::prelude::SystemError> { todo!() }#[system]async fn step_b() -> Result<i32, polaris_ai::system::prelude::SystemError> { todo!() }#[system]async fn shared_fallback() -> i32 { 0 }
let mut graph = Graph::new();let a = graph.add_system_node(step_a);let b = graph.add_system_node(step_b);graph.add_error_handler_for([a, b], |g: &mut Graph| { g.add_system(shared_fallback);});Reading error context with ErrOut<T>
Section titled “Reading error context with ErrOut<T>”Systems inside an error handler can read details about the failure using ErrOut<CaughtError>. CaughtError is an ErrorContext type that the executor populates automatically when routing through an error edge.
use polaris_ai::graph::CaughtError;use polaris_ai::system::param::ErrOut;use polaris_ai::system::system;
#[system]async fn recover(error: ErrOut<CaughtError>) { tracing::error!( "[{}] {} failed after {:?}: {}", error.node_id, error.system_name, error.duration, error.message, );}CaughtError provides five fields:
message– the error message from the failed systemsystem_name– the name of the system that failednode_id– the graph node ID of the failed systemduration– how long the system ran before failingkind– eitherExecution(the system returned an error) orParamResolution(a parameter could not be fetched)
ErrOut<T> works like Out<T> but declares an error context requirement. The framework validates that any system using ErrOut<T> is reachable through an error edge. If it is not, you get a validation error at graph construction time rather than a confusing runtime failure.
Timeout edges
Section titled “Timeout edges”A timeout edge fires when a system exceeds a duration limit. You set the timeout with with_timeout and attach a handler with on_timeout.
use polaris_ai::system::system;use polaris_ai::graph::Graph;use std::time::Duration;
#[system]async fn slow_llm_call() -> Result<String, polaris_ai::system::prelude::SystemError> { todo!() }#[system]async fn timeout_fallback() -> String { "Request timed out.".into() }
let mut graph = Graph::new();graph.system(slow_llm_call) .with_timeout(Duration::from_secs(30)) .on_timeout(|g: &mut Graph| { g.add_system(timeout_fallback); }) .done();If slow_llm_call does not complete within 30 seconds, execution routes to timeout_fallback. You can combine timeout and error handlers on the same system – they are independent edges.
use polaris_ai::system::system;use polaris_ai::graph::Graph;use std::time::Duration;
#[system]async fn risky_operation() -> Result<String, polaris_ai::system::prelude::SystemError> { todo!() }#[system]async fn error_handler() -> String { "Error occurred.".into() }#[system]async fn timeout_handler() -> String { "Timed out.".into() }#[system]async fn next_step() {}
let mut graph = Graph::new();graph.system(risky_operation) .on_error(|g: &mut Graph| { g.add_system(error_handler); }) .with_timeout(Duration::from_secs(30)) .on_timeout(|g: &mut Graph| { g.add_system(timeout_handler); }) .done() .add_system(next_step);Retry policies
Section titled “Retry policies”A retry policy tells the executor to re-run a failed system before routing to error handlers. Polaris provides two built-in strategies.
Fixed delay retries with a constant interval between attempts:
use polaris_ai::system::system;use polaris_ai::graph::{Graph, RetryPolicy};use std::time::Duration;
#[system]async fn call_llm() -> Result<String, polaris_ai::system::prelude::SystemError> { todo!() }
let mut graph = Graph::new();graph.system(call_llm) .with_retry(RetryPolicy::fixed(3, Duration::from_millis(500))) .done();This retries up to 3 times with 500 milliseconds between each attempt. The initial attempt does not count toward the retry limit, so the system runs at most 4 times total.
Exponential backoff doubles the delay after each attempt:
use polaris_ai::system::system;use polaris_ai::graph::{Graph, RetryPolicy};use std::time::Duration;
#[system]async fn call_llm() -> Result<String, polaris_ai::system::prelude::SystemError> { todo!() }
let mut graph = Graph::new();graph.system(call_llm) .with_retry( RetryPolicy::exponential(5, Duration::from_millis(100)) .with_max_delay(Duration::from_secs(10)) ) .done();The delays for this configuration are 100ms, 200ms, 400ms, 800ms, 1600ms – but each is capped at the with_max_delay value of 10 seconds. If all retries are exhausted and the system still fails, execution routes to the error handler (if one exists) or the graph halts.
You can combine retries with error and timeout handlers. The executor tries retries first, and only routes to handlers after all retries are exhausted.
Patterns
Section titled “Patterns”Error handler inside a loop
Section titled “Error handler inside a loop”The ReAct agent example attaches an error handler at the loop level so that any failure inside the loop – whether in the LLM call or tool execution – routes to a shared recovery system.
use polaris_ai::system::system;use polaris_ai::graph::{CaughtError, Graph};use polaris_ai::models::llm::LlmResponse;
#[system]async fn act() -> Result<LlmResponse, polaris_ai::system::prelude::SystemError> { todo!() }#[system]async fn execute_tools() -> Result<(), polaris_ai::system::prelude::SystemError> { todo!() }#[system]async fn finalize() -> ReactState { todo!() }#[system]async fn recover(error: polaris_ai::system::param::ErrOut<CaughtError>) -> ReactState { todo!() }struct ReactState { is_complete: bool }
let mut graph = Graph::new();graph.add_loop::<ReactState, _, _>( "react_loop", |state| state.is_complete, |g| { g.add_system(act); g.add_conditional_branch::<LlmResponse, _, _, _>( "has_tool_calls", |response| response.has_tool_calls(), |tool_branch| { tool_branch.add_system(execute_tools); }, |done_branch| { done_branch.add_system(finalize); }, ); g.add_error_handler(|h| { h.add_system(recover); }); },);The recover system reads the error via ErrOut<CaughtError>, logs it, and returns a ReactState with is_complete: true to exit the loop gracefully. This prevents a single LLM failure from crashing the entire agent.
Retry with fallback to a simpler model
Section titled “Retry with fallback to a simpler model”When an expensive model is rate-limited or unavailable, you can retry a few times and then fall back to a cheaper model.
use polaris_ai::system::system;use polaris_ai::graph::{Graph, RetryPolicy};use std::time::Duration;
#[system]async fn call_primary_model() -> Result<String, polaris_ai::system::prelude::SystemError> { todo!() }#[system]async fn call_fallback_model() -> String { todo!() }
let mut graph = Graph::new();graph.system(call_primary_model) .with_retry(RetryPolicy::exponential(2, Duration::from_millis(500))) .on_error(|g: &mut Graph| { g.add_system(call_fallback_model); }) .done();The primary model gets 2 retry attempts with exponential backoff. If all attempts fail, the error handler runs call_fallback_model instead of halting the graph.
Graceful degradation with context
Section titled “Graceful degradation with context”A recovery system can use the error context to craft a meaningful response rather than returning a generic failure message.
use polaris_ai::graph::CaughtError;use polaris_ai::system::param::{ErrOut, Res, ResMut};use polaris_ai::system::system;
struct UserIO;impl polaris_ai::system::resource::LocalResource for UserIO {}impl UserIO { async fn send(&self, _: String) {} }
#[system]async fn graceful_recovery( error: ErrOut<CaughtError>, user_io: Res<UserIO>,) { let response = match error.kind { polaris_ai::graph::ErrorKind::Execution => { format!("The {} step failed: {}. Please try again.", error.system_name, error.message) } polaris_ai::graph::ErrorKind::ParamResolution => { "An internal configuration error occurred. Please contact support.".into() } }; user_io.send(response).await;}The ErrorKind discriminant lets you tailor the response without parsing error message strings. Execution errors are typically retryable user-facing failures, while parameter resolution errors indicate a structural problem.
Copyright © 2026, Radiant AI Limited.