# Timeouts and deadlines

> **CocoIndex v1, Rust SDK (experimental).** This page documents the CocoIndex **v1** Rust SDK (the `cocoindex` crate). The Rust SDK is experimental: any release may change or remove its API, and it is not yet published on crates.io. The Python SDK pages are at https://cocoindex.io/docs/.
>
> Source: https://cocoindex.io/docs/rust/advanced_topics/timeouts/ · Rust docs index: https://cocoindex.io/docs/rust/llms.txt · Rust SDK tour: https://github.com/cocoindex-io/cocoindex/blob/main/rust/sdk/SHOWCASE.md · Rust examples: https://github.com/cocoindex-io/cocoindex/tree/main/examples/rust

Use `ctx.with_timeout(duration)` when a piece of CocoIndex work should stop after a time budget. The deadline lives on the `Ctx`: `with_timeout` returns a scoped `Ctx`, and the work you call with that `Ctx` sees the deadline.

There is no ambient timeout block like Python's `with coco.timeout(...)`: the budget follows the `Ctx` you pass. A call made with the unscoped `Ctx` does not see it.

The timeout is **cooperative**: CocoIndex checks the time at safe points and returns `Error::DeadlineExceeded` when the deadline has passed. Enforcement is best-effort beyond that — CocoIndex may additionally cancel an in-flight `await` where that is known to be safe (the built-in retry helpers do), but your own code inside an `.await`, such as an HTTP request or database query, is generally not interrupted. Use that library's own timeout for individual I/O calls.

## Quick start

The most common budget is per item of work inside a processing component — one file, one LLM call. Per-item budgets stay meaningful as your source grows (a whole-run limit does not), and they apply no matter how the app runs, including via the `cocoindex update` CLI:

```rust
use std::time::Duration;

#[cocoindex::function(memo, memo_key(target = skip))]
async fn process_file(ctx: &Ctx, file: FileEntry, target: DirTarget) -> Result<()> {
    // This file gets 30 seconds: every call made with `scoped` sees the deadline.
    let scoped = ctx.with_timeout(Duration::from_secs(30));
    let summary = summarize_with_llm(&scoped, &file.content_str()?).await?;
    target.declare_file(ctx, &out_name(&file), summary.as_bytes())
}
```

`with_timeout` returns a new `Ctx`; the original is unchanged. Pass the scoped `Ctx` to the calls that belong to the budget.

If the budget runs out, the component fails with `Error::DeadlineExceeded` like any other processor error: nothing external is written for it, nothing is memoized, and the next update retries it cleanly.

Nested timeouts use the shorter remaining budget:

```rust
let outer = ctx.with_timeout(Duration::from_secs(10));
assert_eq!(outer.remaining_deadline(), Some(Duration::from_secs(10)));

// Still bounded by the outer deadline: the earlier one wins.
let wider = outer.with_timeout(Duration::from_secs(20));
// Narrowed to one second.
let narrower = outer.with_timeout(Duration::from_secs(1));

// `outer` itself keeps its own deadline.
```

## The rule

A timeout follows work the caller is directly waiting for. It does not follow background work or shared work.

| Code shape | What happens |
| --- | --- |
| `UpdateOptions { timeout: Some(..), .. }` on `app.update_with_options(..)` | The root processor's `Ctx` carries the timeout (see [Time-boxing a whole update](#time-boxing-a-whole-update-programmatic-callers)). |
| A `#[cocoindex::function]` called with a scoped `Ctx` | Sees the scoped deadline. A call made with the unscoped `Ctx` does not. |
| `scoped.scope(..)` / `use_mount!` / `scoped.use_mount_each(..)` / `use_mount_each!` | The child inherits the deadline because the parent waits for the child's result. |
| `scoped.mount(..)` / `mount!` / `scoped.mount_each(..)` / `mount_each!` / `auto_refresh` / `mount_live` | The child's `Ctx` carries no deadline. The caller's own wait on `handle.ready()` is bounded by the caller's deadline. |
| `scoped.map(items, ..)` | Item tasks see the deadline. Already-started items run to completion before the error is returned. |
| Batched function body | A physical batch does not inherit any individual caller's deadline, because a batch can contain work from multiple callers. A batched body that needs a bound carries its own — `ApiEmbedder`, for example, bounds each request with its own timeout (`with_timeout`). |
| Target writes and connector sink calls | Do not inherit the processor deadline. Once processing has succeeded, CocoIndex lets target synchronization finish consistently. |
| Long loop inside a processor | Add `ctx.check_cancellation()?` where you want the loop to stop promptly. |

## Foreground child work

Use `ctx.scope(..)` or `use_mount!` when the parent depends on the child's value. The timeout applies to both parent and child:

```rust
#[cocoindex::function]
async fn parse_file(ctx: &Ctx, file: FileEntry) -> Result<ParsedDoc> {
    let text = file.content_str()?;
    ctx.check_cancellation()?;
    Ok(parse(&text))
}

app.update(move |ctx| async move {
    let scoped = ctx.with_timeout(Duration::from_secs(30));
    // A foreground child mounted through `scope` receives the caller's deadline.
    let parsed = scoped
        .scope(&"parse", move |child| async move { parse_file(&child, file).await })
        .await?;
    index(parsed);
    Ok(())
})
.await?;
```

If the deadline passes while `parse_file` is inside an `.await`, CocoIndex does not interrupt it. The timeout is observed when control reaches the next CocoIndex check, such as `ctx.check_cancellation()?`.

## Background child work

Use `ctx.mount(..)` or `ctx.mount_each(..)` when the child is independent background work. The parent's timeout can stop the parent from waiting forever, but it does not become the child's timeout:

```rust
let scoped = ctx.with_timeout(Duration::from_secs(30));
let handle = scoped
    .mount(&"first-file", |child| async move {
        // `child` carries no deadline: the parent's budget does not reach it.
        process_file(&child, first).await
    })
    .await?;

// This wait is bounded by the parent's deadline.
// The child processor itself remains independent.
handle.ready().await?;
```

If the parent times out while waiting for `handle.ready()`, the timeout belongs to the parent wait. It does not retroactively cancel the child processor.

## What happens on timeout

The timing contract is deliberately loose:

- Any CocoIndex-managed call **may** return `Error::DeadlineExceeded` if the deadline passes before it returns; `err.is_deadline_exceeded()` tests for it.
- CocoIndex checkpoints — `ctx.check_cancellation()`, a memoized `#[cocoindex::function]` call, engine calls such as `ctx.scope(..)` and `ctx.mount(..)` — **guarantee** to return it if the deadline has already passed when they are called.

Don't rely on the exact point where the error is returned. It can move between versions, and enforcement can get tighter where cancelling is safe (the built-in retry helpers already cancel an in-flight attempt at the deadline).

What you can rely on is the outcome:

- A processor run that times out applies no target writes and memoizes nothing; the next update retries it cleanly.
- Completed work stays completed: committed components, finished `ctx.map(..)` items, and in-progress target synchronization are never rolled back or interrupted by a timeout.

## Add checks in long processors

CocoIndex already checks common boundaries, but a long processor loop should add explicit checks:

```rust
#[cocoindex::function]
async fn import_rows(ctx: &Ctx, rows: Vec<Row>, table: &TableTarget) -> Result<()> {
    for row in rows {
        table.declare_row(ctx, &transform(row))?;
        ctx.check_cancellation()?;
    }
    Ok(())
}
```

This makes the loop stop near the deadline instead of waiting until the whole function returns.

## Long I/O still needs its own timeout

`ctx.with_timeout(..)` does not replace client-level timeouts: an `.await` on an HTTP client or a database driver is not interrupted. Give that client its own timeout, and call `ctx.check_cancellation()?` after it returns.

Think of the two budgets as complementary:

- client timeouts bound one external call
- the `Ctx` deadline bounds CocoIndex work between safe checks

## Time-boxing a whole update (programmatic callers)

When you run an update yourself and the surrounding environment has its own clock (a CI job, a serverless handler, a shared cron window), you can put the budget around the whole update:

```rust
let result = app
    .update_with_options(
        UpdateOptions {
            timeout: Some(Duration::from_secs(10 * 60)),
            ..UpdateOptions::default()
        },
        |ctx| async move {
            // ...
            Ok(())
        },
    )
    .await;
if let Err(err) = &result {
    if err.is_deadline_exceeded() {
        println!("stopped after the 10-minute budget");
    }
}
```

`update_blocking_with_options` and `start_update_with_options` take the same `UpdateOptions`. The root processor's `Ctx` carries the timeout.

Because components commit independently and the engine rolls forward, a whole-update timeout never wastes completed work: everything that finished is committed and stays committed; the timeout only stops new progress, and the **next update resumes where this one left off**. An update with `timeout: Some(Duration::from_secs(10 * 60))` means "index as much as possible in ten minutes, cleanly."

## Retries

The built-in API operations (`ApiEmbedder`, `ApiTranscriber`, `ApiChatClient` and the `LlmPairResolver` built on it) retry transient failures inside one bound per request: the op's own `timeout` (10 minutes by default, set with `with_timeout`), shortened to the `Ctx`'s remaining deadline when that is earlier. A `Ctx` deadline can only shorten the bound, never extend it.

Fast failures (HTTP 408, 409, 425, 429, 5xx, a refused or reset connection) are retried with exponential backoff. A request that times out is not retried, and no backoff sleep runs past the bound. When the `Ctx` deadline is what ran out, the op returns `Error::DeadlineExceeded`; when the op's own timeout ran out, it returns an error that names the request.

The SDK has no public retry helper for your own calls. Bound them with the client's timeout and `ctx.remaining_deadline()`.

## API

```rust
let scoped = ctx.with_timeout(Duration::from_secs(30));
```

Returns a clone of the `Ctx` with the timeout applied. An earlier deadline on the current `Ctx` wins.

```rust
ctx.with_timeout_scope(Duration::from_secs(30), |ctx| async move {
    // Calls in this body use the scoped deadline.
    Ok(())
})
.await?;
```

Runs a closure with a scoped `Ctx`. Name the closure argument `ctx` to shadow the outer context.

```rust
ctx.check_cancellation()?;
```

Returns `Error::DeadlineExceeded` if the current deadline has passed.

```rust
ctx.has_deadline();        // bool
ctx.remaining_deadline();  // Option<Duration>
err.is_deadline_exceeded() // bool, on a cocoindex::Error
```
