Error handling & exception handlers

How CocoIndex isolates component failures, recovers from interrupted updates via two-phase processing, and lets you register exception handlers globally on the environment builder or for one scope with ctx.with_exception_handler to observe background errors.

Version
v 1.0.14
Last reviewed
Nov 4, 2026
ExperimentalThe Rust SDK is experimental. It is outside CocoIndex's compatibility promise: any release, including a minor one, may change or remove its API. It is not yet published on crates.io.

CocoIndex isolates component failures and provides APIs for observing errors, handling interrupted updates, and reacting to failures in production.

For a quick overview of failure isolation and the two-phase model, see What happens when a component fails in the Processing Component guide.

The guiding principle

Whether a CocoIndex call returns an error on failure depends on what’s on the critical path for the call to finish and return. Things on that path that fail surface as errors; things off that path don’t.

APIWhat it takes to returnReturns an error on failure?
ctx.mount / mount!, ctx.mount_each / mount_each!, LiveComponentOperator::start_update / start_deletethe work is scheduled (returns a handle)No — execution runs in the background
handle.ready().await (any of the above)reaches ready stateNo by default — logged at ERROR; opt into propagation by installing a handler that returns Err
LiveComponentOperator::update_full, update, deletereaches ready state of the full cycle or the childNo by default — same shape as handle.ready()
ctx.scope / use_mount!, ctx.use_mount_each / use_mount_each!the inner component’s value is producedYes — component success is on the critical path
app.drop_state().await (and cocoindex drop)GC succeeds at every levelYes, at any depth — leaking target state would otherwise go silent

Why ready/update_full tolerate errors by default: handle.ready() is usually not awaited (callers spawn many mounts in a loop and rely on the parent waiting for its children), so an error returned to nobody would silently disappear. Logging the failure gives operators a signal regardless. The same applies to update_full — a periodic refresh that died on the first cycle failure would be worse than one that logs and retries on the next tick.

Why app.drop_state fails at every level: clearing tracking records is only safe if every component’s delete actually ran its sink calls. If a descendant delete silently fails, app.drop_state() would return Ok while target state is still live in your database — a leak. So failures at any depth surface as an error.

How to opt into propagation

Install an exception handler (described below). A handler that returns Ok(()) swallows; a handler that returns Err(_) propagates through handle.ready() (or update_full()) up to your awaiting code. For app.drop_state, propagation is built in — you don’t need to install anything.

Live components follow the same principle: their update_full, update and delete calls route failures through this chain, with mount_kind set to UpdateFull, Update or Delete.

Different from Python

The Rust LiveComponentOperator has no report_exception: return the error from process_live instead.

Processing and submit phases

CocoIndex processes each component in two phases:

  1. Processing — runs your function, declares target states in memory. This phase is side-effect-free. If it fails (e.g., a parsing error, an API timeout), no writes were attempted.
  2. Submit — writes changes to target backends. This phase only runs after processing completes successfully.

This separation means a processing failure never leaves partial data in your targets.

Interrupted updates and recovery

An update can be interrupted by various events: a process kill (SIGKILL), Ctrl+C (SIGINT), an unhandled error, or a target backend failure during submit.

What state is left behind?

CocoIndex’s internal database (LMDB) uses transactions, so its own state is always consistent even after a crash. CocoIndex tracks all possible states a target could be in — if an update is interrupted partway through a commit, both the old and new states are retained as possibilities. This ensures no state is ever lost.

Recovery is automatic. On the next app update, CocoIndex computes the current desired state and reconciles against all possible previous states. The target connector converges the target to the correct state regardless of whether the previous commit partially succeeded or never ran.

For details on how target handlers deal with multiple possible previous states after an interruption, see Custom Target Connector — Handle multiple previous states.

Monitoring errors

app.start_update(...) returns an UpdateHandle that exposes processing stats, including error counts:

let mut handle = app.start_update(|ctx| async move {
    // ...
    Ok(())
})?;

// Poll stats at any time
let detailed = handle.detailed_stats_snapshot();
println!("Errors: {}", detailed.total().num_errors);

// Follow progress until the update finishes
loop {
    if handle.changed().await?.is_done() {
        break;
    }
    println!("{} errors so far", handle.detailed_stats_snapshot().total().num_errors);
}
let result = handle.result().await?;

See Progress monitoring for the full UpdateHandle API.

Exception handlers

For background-mounted components (ctx.mount / mount!, ctx.mount_each / mount_each!, live components and ctx.auto_refresh), you can register exception handlers to observe or react to failures — for example, to send alerts, record metrics, or implement custom logic.

CocoIndex supports two levels of exception handlers:

  • Global (environment-level): registered once with EnvironmentBuilder::exception_handler, usually in your lifespan function; applies to all background mounts in the environment.
  • Scoped: ctx.with_exception_handler(handler) returns a scoped Ctx; the handler applies to the background mounts made from that Ctx. ctx.mount_live_with_handler attaches a handler to one live component.

A handler is Fn(&Error, &ExceptionContext) -> Result<()>: return Ok(()) to swallow the failure, Err(_) to propagate it.

Note

Exception handlers only apply to background mounts. ctx.scope / use_mount! and use_mount_each! return the child’s error directly to the caller, since the parent has an explicit dependency on the result.

Global exception handler

Register a handler inside your #[cocoindex::lifespan] function using builder.exception_handler(...):

#[cocoindex::lifespan]
async fn lifespan(builder: EnvironmentBuilder) -> Result<EnvironmentBuilder> {
    Ok(builder.exception_handler(|err, exc| {
        println!("[{}] {:?} failed at {}: {err}", exc.env_name, exc.mount_kind, exc.stable_path);
        Ok(())
    }))
}

The same method works on an Environment::builder() you build yourself.

This replaces the default “log error” behavior for all background mounts in the environment.

Scoped exception handler

Use ctx.with_exception_handler(handler) to get a Ctx whose background mounts go through the handler first:
#[cocoindex::function]
async fn process_all(ctx: &Ctx, files: Vec<FileEntry>) -> Result<()> {
    let scoped = ctx.with_exception_handler(|err, exc| {
        println!("Failed processing {}: {err}", exc.stable_path);
        Ok(())
    });
    for file in files {
        let key = file.relative_path().display().to_string();
        scoped
            .mount(&key, move |child| async move { process_file(&child, file).await })
            .await?;
    }
    Ok(())
}

Only mounts made from scoped see the handler. A mount made from the unscoped ctx goes to the global handler.

The handler applies to every background mount made from the scoped Ctx (or a Ctx derived from it, for example with with_subpath), including every item of a mount_each and every child of a live component mounted from it.

Different from Python

A child component’s Ctx inherits the handler chain of the Ctx it was mounted from, so a handler installed with ctx.with_exception_handler also sees the failures of the mounts that child makes itself, at any depth. In Python, a scoped handler does not reach the mounts those components make themselves. The global handler is the outermost entry of every chain.

Handler type

The type alias is:

pub type ExceptionHandler =
    Arc<dyn Fn(&Error, &ExceptionContext) -> Result<()> + Send + Sync + 'static>;

EnvironmentBuilder::exception_handler, ctx.with_exception_handler and ctx.mount_live_with_handler accept any closure with that signature and wrap it.

Different from Python

Rust handlers are synchronous closures; Python also accepts async handlers.

What err is

Handlers receive the same Error that a foreground call such as ctx.scope(...) would return for the same failure. A failure your own code returned with Error::user(..) arrives as Error::User(inner); inner.downcast_ref::<T>() recovers its type. Error::DeadlineExceeded is the cooperative deadline. An error a handler returns keeps its value as well: handle.ready() returns exactly what the last handler in the chain returned.

// The handler wraps the component's error in its own error type and returns it.
let app = Environment::builder()
    .db_path(db_path)
    .exception_handler(|err, _exc| Err(Error::user(DeadLetter(err.to_string()))))
    .build()
    .await?
    .app("my_app")
    .await?;

// `handle.ready()` returns the same value the last handler returned.
let handle = ctx
    .mount(&"child", |_child| async move { Err(Error::engine("boom")) })
    .await?;
if let Err(e) = handle.ready().await {
    let is_dead_letter = match &e {
        Error::User(inner) => inner.downcast_ref::<DeadLetter>().is_some(),
        _ => false,
    };
    // ...
}

ExceptionContext fields

Your handler receives an ExceptionContext struct with information about the failure:

FieldTypeNotes
env_nameStringName of the app the failing component runs under
stable_pathStringStable path of the failing component
parent_stable_pathOption<String>Set for a child mount or delete
processor_nameOption<String>Name of the processor that failed, if known
mount_kindMountKindMount (ctx.mount / mount!, ctx.mount_each / mount_each!), UpdateFull (a live component’s full pass, or an auto_refresh cycle), Update or Delete (the live component operator)
is_backgroundbooltrue for a background mount; for a live component, true once it is ready and running in live mode
sourceExceptionSourceComponent for the component’s own error; Handler for an error a nearer handler returned
Different from Python

There is no original_exception field in Rust. The error a handler returns replaces the component’s error for the next handler.

Handler stacking and fallback

Handlers are stacked: the most specific (innermost) handler runs first.

If the innermost handler returns Err, the next outer handler is called with that new error. In this case exc.source is ExceptionSource::Handler.

This continues up the stack. If every handler returns Err, the last handler’s error propagates through handle.ready() (see How to opt into propagation). If no handler is registered, CocoIndex falls back to the built-in behavior: logging the error at ERROR level, with no crash.

#[cocoindex::lifespan]
async fn lifespan(builder: EnvironmentBuilder) -> Result<EnvironmentBuilder> {
    Ok(builder.db_path("...").exception_handler(|err, exc| {
        match exc.source {
            // A handler itself failed: `err` is the error that handler returned.
            ExceptionSource::Handler => println!("Handler error: {err}"),
            ExceptionSource::Component => println!("Component error: {err}"),
        }
        Ok(())
    }))
}

app.update(|ctx| async move {
    let scoped = ctx.with_exception_handler(|err, _exc| {
        println!("inner: {err}");
        Err(Error::engine("handler failed")) // falls through to the global handler
    });
    scoped
        .mount(&"child", |_child| async move { Err(Error::engine("boom")) })
        .await?;
    Ok(())
})
.await?;

Users who never register handlers see identical behavior to the default — errors from background mounts are logged at ERROR level and siblings continue unaffected.