> ## Documentation Index
> Fetch the complete documentation index at: https://webscraping.titannet.io/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Idempotency and retries

> Use idempotency keys so a retried agent tool call replays the original run instead of starting a second billable one.

Agents retry. A tool call times out, a network hiccup drops a response, an agent loop repeats a step—and without protection, each retry starts fresh billable work.

Pass an `idempotency_key` and Titan recognizes the repeat, returning the original run instead.

## How it works

```mermaid theme={null}
flowchart TD
    A[Tool call with idempotency_key] --> B{Key seen before?}
    B -->|No| C[Start run, remember key]
    B -->|Yes, run in flight| D[Return the in-flight run]
    B -->|Yes, run finished| E[Replay the original result]
    C --> F[Return results]
    D --> F
    E --> F
```

Keys are scoped to **your user and the capability**, so the same key used for a search and a fetch does not collide. Entries are retained for **24 hours**.

## Which tools accept it

`titan_search`, `titan_fetch`, `titan_crawl`, and `titan_run_template`—every tool that starts billable work.

`titan_get_run` and `titan_list_templates` do not need it. They are already free and side-effect-free.

## Using a key

```json theme={null}
{
  "urls": ["https://example.com/pricing"],
  "format": "markdown",
  "idempotency_key": "pricing-check-2026-07-27"
}
```

Choose keys that identify the *work*, not the attempt:

| Good                             | Why                                           |
| -------------------------------- | --------------------------------------------- |
| `pricing-check-2026-07-27`       | Same logical job on the same day deduplicates |
| `research-session-8f3a-step-2`   | Stable across retries within one agent run    |
| A hash of the request parameters | Identical requests collapse automatically     |

| Avoid                          | Why                                         |
| ------------------------------ | ------------------------------------------- |
| A fresh UUID per attempt       | Never matches, so it protects nothing       |
| A timestamp to the millisecond | Same as above                               |
| A constant like `"key"`        | Collapses unrelated work into one stale run |

## Replay warnings

A deduplicated call tells you so, in `warnings`:

| Code                 | Meaning                                                                  |
| -------------------- | ------------------------------------------------------------------------ |
| `idempotency_active` | The original run is still in flight. You received its `run_id`; poll it. |
| `idempotency_replay` | The original run already finished. You received its result.              |

Treat both as success. The work is happening or has happened—your retry did the right thing by not duplicating it.

```json theme={null}
{
  "run_id": "3c7d1a92-6f48-4b25-9e03-7d1a4c8b2f65",
  "status": "running",
  "warnings": [
    { "code": "idempotency_active", "message": "a run for this idempotency key is already in progress" }
  ]
}
```

## Retrying without a key

If you did not pass a key and a call fails, decide by error code before retrying.

| Error                        | Retryable | Guidance                                         |
| ---------------------------- | :-------: | ------------------------------------------------ |
| `backend_unavailable`        |    Yes    | Retry with backoff                               |
| `timeout`                    |    Yes    | Retry, or poll if you have a `run_id`            |
| `provider_rate_limited`      |    Yes    | Back off longer, or switch `search_provider`     |
| `provider_failed`            | Sometimes | Retry once; if it persists, try another provider |
| `invalid_request`            |     No    | Fix the parameters                               |
| `unauthorized` / `forbidden` |     No    | Fix the key or its scopes                        |
| `insufficient_credits`       |     No    | Top up, or request less work                     |
| `limit_exceeded`             |     No    | Lower the limit                                  |
| `unsafe_url`                 |     No    | Use a different URL                              |
| `template_not_found`         |     No    | Use an allowlisted slug                          |

Every error body carries a `retryable` boolean, so an agent can branch on it without hardcoding this table.

## Do not retry a run that is still going

The most expensive mistake is treating a timeout as a failure. When a tool returns `running` or `queued` with a `next_step` warning, the run is alive. Re-issuing the call starts a second run for the same work, and you pay for both.

```text theme={null}
Wrong:  titan_fetch(...) → running → titan_fetch(...) again
Right:  titan_fetch(...) → running → titan_get_run(run_id)
```

## Retry with backoff

```typescript theme={null}
async function callWithRetry(client, name, args, maxAttempts = 3) {
  const key = `${name}-${hashOf(args)}`;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const result = await client.callTool({
      name,
      arguments: { ...args, idempotency_key: key },
    });

    if (!result.isError) return result;

    const error = JSON.parse(result.content[0].text);
    if (!error.retryable || attempt === maxAttempts) throw new Error(error.message);

    await sleep(1000 * 2 ** (attempt - 1));
  }
}
```

The stable `idempotency_key` means even if a "failed" call actually started a run, the retry joins it rather than duplicating it.

## Next steps

* [Errors and warnings](/docs/mcp/errors-and-warnings) — every code and its meaning
* [Runs and results](/docs/mcp/runs-and-results) — polling instead of retrying
* [Credits and usage](/docs/mcp/credits-and-usage) — why duplicate runs cost real money
