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.
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.
| API | What it takes to return | Returns an error on failure? |
|---|---|---|
ctx.mount / mount!, ctx.mount_each / mount_each!, LiveComponentOperator::start_update / start_delete | the work is scheduled (returns a handle) | No — execution runs in the background |
handle.ready().await (any of the above) | reaches ready state | No by default — logged at ERROR; opt into propagation by installing a handler that returns Err |
LiveComponentOperator::update_full, update, delete | reaches ready state of the full cycle or the child | No by default — same shape as handle.ready() |
ctx.scope / use_mount!, ctx.use_mount_each / use_mount_each! | the inner component’s value is produced | Yes — component success is on the critical path |
app.drop_state().await (and cocoindex drop) | GC succeeds at every level | Yes, 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.
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:
- 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.
- 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 scopedCtx; the handler applies to the background mounts made from thatCtx.ctx.mount_live_with_handlerattaches a handler to one live component.
A handler is Fn(&Error, &ExceptionContext) -> Result<()>: return Ok(()) to swallow the failure, Err(_) to propagate it.
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
Usectx.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.
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.
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:
| Field | Type | Notes |
|---|---|---|
env_name | String | Name of the app the failing component runs under |
stable_path | String | Stable path of the failing component |
parent_stable_path | Option<String> | Set for a child mount or delete |
processor_name | Option<String> | Name of the processor that failed, if known |
mount_kind | MountKind | Mount (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_background | bool | true for a background mount; for a live component, true once it is ready and running in live mode |
source | ExceptionSource | Component for the component’s own error; Handler for an error a nearer handler returned |
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.