# 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

**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

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.

```rust
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

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.

```rust
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.

```rust
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>`

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.

```rust
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 system
* `system_name` – the name of the system that failed
* `node_id` – the graph node ID of the failed system
* `duration` – how long the system ran before failing
* `kind` – either `Execution` (the system returned an error) or `ParamResolution` (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

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`.

```rust
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.

```rust
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

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:

```rust
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:

```rust
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

### 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.

```rust
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

When an expensive model is rate-limited or unavailable, you can retry a few times and then fall back to a cheaper model.

```rust
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

A recovery system can use the error context to craft a meaningful response rather than returning a generic failure message.

```rust
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.
