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

# Recover from Errors

> Use an error's outcome to tell whether your work started, then recover by symptom without creating duplicates.

When a call fails, read its `retry` field to learn whether to send it again. Read `outcome` to learn whether your work started.

| You Want To | Go To |
| - | - |
| Read an error's fields | [Read the Error Response](#read-the-error-response) |
| Know whether to send a call again | [Read the Retry Field](#read-the-retry-field) |
| Let the client resend for you | [Let the Client Retry for You](#let-the-client-retry-for-you) |
| Know whether your work started | [Check Whether Your Work Started](#check-whether-your-work-started) |
| Resolve an `unknown` or `partial` result | [Inspect an Uncertain Operation](#inspect-an-uncertain-operation) |
| Resend a lost create request | [Recover a Lost Creation Response](#recover-a-lost-creation-response) |
| Look up a symptom | [Fix It by Symptom](#fix-it-by-symptom) |

## Read the Error Response

Every HTTP error has this shape. These values come from a request to a path that does not exist.

```json theme={"theme":"css-variables"}
{
  "error": {
    "code": "not_found",
    "message": "No BoxRouter API operation has this method and path.",
    "outcome": "not_started",
    "retry": "check_outcome",
    "retry_after_seconds": null,
    "doc_url": "https://boxrouter.ai/docs/error-codes#not_found",
    "request_id": "<REQUEST_ID>",
    "fix": "Check the method and path against the API reference; every path starts with /api/v1.",
    "operation_id": null,
    "provider": null,
    "native_code": null,
    "native_status": null,
    "native_message": null,
    "details": {}
  }
}
```

| Field | Type | Meaning |
| - | - | - |
| `code` | string | The stable category. [Error codes](https://boxrouter.ai/docs/error-codes.md) lists every code and its `retry` value. |
| `message` | string | Text for people. Do not branch on it. |
| `outcome` | `not_started`, `completed`, `partial`, or `unknown` | What is known about the work. |
| `retry` | `safe`, `after_fix`, `check_outcome`, or `never` | Whether to send the call again. Branch on it. |
| `retry_after_seconds` | integer or `null` | How long to wait before a `safe` resend; `null` when there is no wait to name. |
| `doc_url` | string or `null` | The page that explains this code: the [error codes](https://boxrouter.ai/docs/error-codes.md) entry for it. Open it from your logs, or show it to the person who sees the error. |
| `request_id` | string | Quote it when you ask for help. |
| `fix` | string or `null` | The change to make. Every `4xx` response has one. |
| `operation_id` | string or `null` | Reads the result of an uncertain operation. |
| `details` | object | Context that depends on the code. |
| `provider`, `native_code`, `native_status`, `native_message` | string, integer, or `null` | The provider's own error, unchanged, when the failure came from a provider. |

## Read the Retry Field

`retry` has four values. The HTTP status never decides a retry: a `409` can be `safe` or `after_fix`.

| `retry` | What It Means | What to Do |
| - | - | - |
| `safe` | Nothing ran, and the cause is temporary. | Send the same call again with the same idempotency key after `retry_after_seconds`. |
| `after_fix` | The call fails the same way until you change something. | Change the request, your account or the state that `fix` names, then send it again. |
| `check_outcome` | The `outcome` is `unknown` or `partial`. | Read the `operation_id` before any resend. See [Inspect an Uncertain Operation](#inspect-an-uncertain-operation). |
| `never` | Sending the call again cannot succeed. | Do not resend. The [code's entry](https://boxrouter.ai/docs/error-codes.md) says what to do instead. |

Treat a `retry` value you do not know as `never`. [Error codes](https://boxrouter.ai/docs/error-codes.md#read-the-columns) lists each code's value.

## Let the Client Retry for You

The SDKs and the CLI resend `retry: safe` errors twice, with the same idempotency key, and never any other value. Pass `max_retries` or `maxRetries` to change the count; `0` turns it off. This `not_found` raises at once because its `retry` is not `safe`:

```python Python theme={"theme":"css-variables"}
from boxrouter import APIError, BoxRouter

with BoxRouter(max_retries=4) as client:
    try:
        client.sandboxes.exec("sb_missing", "echo hello")
    except APIError as error:
        print(error.retry)
```

```typescript TypeScript theme={"theme":"css-variables"}
import { APIError, BoxRouter } from '@boxrouter/sdk';

const client = new BoxRouter({ maxRetries: 4 });
try {
  await client.api.commands.run({ path: { sandbox_id: 'sb_missing' }, body: { command: 'echo hello' } });
} catch (error) {
  if (!(error instanceof APIError)) throw error;
  console.log(error.retry);
} finally {
  await client.close();
}
```

```bash CLI theme={"theme":"css-variables"}
boxrouter exec sb_missing 'echo hello' --json-errors 2>&1 | jq -r .error.retry
```

Output

```text theme={"theme":"css-variables"}
check_outcome
```

The wait is `retry_after_seconds`, or 0.5 seconds doubling to 8 when it is `null`; a longer wait is raised, not slept. The CLI always uses two. Over plain HTTP, wait and send the same request with the same `Idempotency-Key`.

## Decide by Outcome, Not by Status

The HTTP status does not say whether the work happened. A `202` is a success status, not a completion: the work is still pending. A `4xx` or `5xx` can carry any `outcome`, including `unknown` and `completed`.

![A failed call carries outcome. not\_started: nothing ran anywhere; resend when retry is safe, otherwise follow the fix. unknown: the provider may have acted; don't send it again yet, inspect the operation. partial: part of the change happened; do only what is missing. completed: the change happened; don't send it again, read the resource.](https://mintcdn.com/nv-4965711d/RMVlH8Z1jdQT60zr/images/diagrams/error-outcome-light.svg?fit=max\&auto=format\&n=RMVlH8Z1jdQT60zr\&q=85\&s=f6ea61ec48631ba8d2cd4eb910313477)

![A failed call carries outcome. not\_started: nothing ran anywhere; resend when retry is safe, otherwise follow the fix. unknown: the provider may have acted; don't send it again yet, inspect the operation. partial: part of the change happened; do only what is missing. completed: the change happened; don't send it again, read the resource.](https://mintcdn.com/nv-4965711d/RMVlH8Z1jdQT60zr/images/diagrams/error-outcome-mobile-light.svg?fit=max\&auto=format\&n=RMVlH8Z1jdQT60zr\&q=85\&s=54879d01160f739bd3a2803bd511be98)

Check the operation before you act on `unknown` or `partial`.

## Check Whether Your Work Started

| `outcome` | What Happened | What to Do |
| - | - | - |
| `not_started` | Nothing ran anywhere. | Follow `retry`: resend a `safe`, or make the change `fix` names. |
| `unknown` | The provider may have acted, but the answer was lost or cut short. | Do not send it again yet. [Inspect the operation](#inspect-an-uncertain-operation). |
| `partial` | Part of the change happened. A failed launch is `partial` once its sandbox deletion is confirmed, because setup commands may have run. | Read the operation and the resource, then do only what is missing. Check the external effects of launch setup. |
| `completed` | The change happened; a later step failed, such as reading the result. | Do not send it again. Read the resource. |

An `unknown` or `partial` outcome always carries `retry: check_outcome`, whatever the code's usual value.

An `unknown` `504 timeout` is left for a command whose answer BoxRouter lost. Its operation reads `completed` with
`settled_by: wait_timeout`, which means BoxRouter stopped waiting, not that the command ended.

A process-start failure after dispatch is `partial` or `unknown`, and a process `wait_for_port` ended is `partial`.
Check the sandbox and any effects before starting another command. A failed process removal can
also report `not_started` after it began removing retained output; check what remains before retrying.

Two cases have no `outcome` or `retry` field:

* A command that exits nonzero or reaches `timeout_seconds` is a [result](https://boxrouter.ai/docs/commands.md#when-a-command-runs-too-long), not an
  error. `exec` returns its `exit_code`, `timed_out`, `stdout` and `stderr`; `check=True` raises `CommandFailed` for either.
* A transport failure has no BoxRouter response. If the client never connected, its message
  says the request was not sent: treat that as `not_started`. If the connection dropped after
  sending, treat it as `unknown`.

## Inspect an Uncertain Operation

An error with `retry: check_outcome` can include an `operation_id`. Reading it never repeats the work.
In the SDKs, `operations.wait` reads it until the outcome is known and raises a timeout error if it
stays `unknown`. The CLI and HTTP examples read it once. Inspect the resource and its effects before retrying if no ID is available. Keep the request ID
and idempotency key.

The CLI and HTTP examples need `jq`; the HTTP example also needs `curl`. For real work, inspect effects before
deleting an uncertain sandbox.

In this example the sandbox does not exist, so the call fails before anything runs and reports `not_started`. Its `retry` is
`check_outcome`, but there is no `operation_id`, so nothing is read.

```python Python theme={"theme":"css-variables"}
from boxrouter import BoxRouter
from boxrouter.api_error import APIError

with BoxRouter() as client:
    try:
        client.sandboxes.exec("sb_missing", "echo hello")
    except APIError as error:
        print(error.code, error.outcome, error.retry)
        if error.retry == "check_outcome" and error.operation_id:
            print(client.operations.wait(error.operation_id).outcome)
```

```typescript TypeScript theme={"theme":"css-variables"}
import { APIError, BoxRouter } from '@boxrouter/sdk';

const client = new BoxRouter();
try {
  await client.api.commands.run({ path: { sandbox_id: 'sb_missing' }, body: { command: 'echo hello' } });
} catch (error) {
  if (!(error instanceof APIError)) throw error;
  console.log(error.code, error.outcome, error.retry);
  if (error.retry === 'check_outcome' && error.operationId) {
    const operation = await client.operations.wait(error.operationId);
    console.log(operation.outcome);
  }
} finally {
  await client.close();
}
```

```bash CLI theme={"theme":"css-variables"}
error=$(boxrouter exec sb_missing 'echo hello' --json-errors 2>&1 || true)
jq -er '.error | "\(.code) \(.outcome) \(.retry)"' <<<"$error"
operation_id=$(jq -r '.error | select(.retry == "check_outcome") | .operation_id // empty' <<<"$error")
[ -z "$operation_id" ] || boxrouter api operations get "$operation_id" | jq -er .outcome
```

```bash HTTP theme={"theme":"css-variables"}
API=${BOXROUTER_BASE_URL:-https://api.boxrouter.ai/api/v1}
AUTH="Authorization: Bearer $BOXROUTER_API_KEY"
error=$(curl -sS "$API/sandboxes/sb_missing/commands" -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"command": "echo hello"}')
jq -er '.error | "\(.code) \(.outcome) \(.retry)"' <<<"$error"
operation_id=$(jq -r '.error | select(.retry == "check_outcome") | .operation_id // empty' <<<"$error")
[ -z "$operation_id" ] || curl -sSf "$API/operations/$operation_id" -H "$AUTH" | jq -er .outcome
```

Output

```text theme={"theme":"css-variables"}
not_found not_started check_outcome
```

If the operation still reads `unknown`, look at the resource as well: the sandbox's state, or the files the
command writes. A missing log line does not prove nothing happened. If it stays `unknown`, contact support with the
`operation_id` and the `idempotency_key`. Do not create a new idempotency key to get around an unresolved operation.

## Recover a Lost Creation Response

Send the create request again with the same body and `Idempotency-Key`. You choose this key to identify
one particular call. BoxRouter looks it up in its records:

* If the first request completed, you get the same sandbox and nothing new is created.
* If it is still unresolved, you get `409` with its `operation_id`, and nothing is sent to a
  provider.
* If BoxRouter never received it, this request creates the sandbox.
* If the body differs, you get `409 conflict` and nothing happens.

The SDKs and CLI generate a new key for each call unless you pass one, so pass your own to be
able to resend. You can also find the sandbox by a label you set, without the key:

```python Python theme={"theme":"css-variables"}
import uuid
from boxrouter import BoxRouter

key = str(uuid.uuid4())
print(f"Idempotency key: {key}", flush=True)
label = f"recovery-{key}"
with BoxRouter() as client:
    first = client.sandboxes.create(idempotency_key=key, labels={"run": label})
    try:
        again = client.sandboxes.create(idempotency_key=key, labels={"run": label})
        print(first.id == again.id)

        found = client.sandboxes.list(label=[f"run={label}"], lifecycle="live")
        print([sandbox.id == first.id for sandbox in found.data])
    finally:
        first.destroy()
```

```typescript TypeScript theme={"theme":"css-variables"}
import { randomUUID } from 'node:crypto';
import { BoxRouter } from '@boxrouter/sdk';

const key = randomUUID();
console.log(`Idempotency key: ${key}`);
const label = `recovery-${key}`;
await using client = new BoxRouter();
await using first = await client.sandboxes.create(
  { labels: { run: label } },
  { idempotencyKey: key },
);
const again = await client.sandboxes.create(
  { labels: { run: label } },
  { idempotencyKey: key },
);
console.log(first.id === again.id);

const found = await client.sandboxes.list({
  label: [`run=${label}`],
  lifecycle: 'live',
});
console.log(found.data.map((sandbox) => sandbox.id === first.id));
```

```bash CLI theme={"theme":"css-variables"}
(
  set -euo pipefail
  key=$(uuidgen)
  echo "Idempotency key: $key"
  label="recovery-$key"
  id=$(boxrouter create --idempotency-key "$key" --labels "{\"run\": \"$label\"}")
  cleanup() { boxrouter destroy "$id" >/dev/null || echo "Check sandbox $id for cleanup." >&2; }
  trap cleanup EXIT
  again=$(boxrouter create --idempotency-key "$key" --labels "{\"run\": \"$label\"}")
  [[ "$id" == "$again" ]] && echo True || echo False
  boxrouter api sandboxes list --label "run=$label" --lifecycle live |
    jq -c --arg id "$id" '[.data[] | .id == $id]'
)
```

Output

```text theme={"theme":"css-variables"}
Idempotency key: 8c976f73-04d0-482d-8c51-926d0b7b782e
True
[True]
```

Once operation history is removed, the same key returns `409 conflict` and creates nothing. See the
[retention policy](https://boxrouter.ai/docs/data-retention.md#what-boxrouter-keeps). Search by label, or ask support if you are unsure.

## Fix It by Symptom

| Symptom | Cause | Next Step |
| - | - | - |
| [`401 unauthenticated`](https://boxrouter.ai/docs/error-codes#unauthenticated) | The API key is missing, mistyped, or revoked. | Set `BOXROUTER_API_KEY` from **API Keys**. |
| [`403 forbidden`](https://boxrouter.ai/docs/error-codes#forbidden) | The key lacks permission or an access check refused the request. | Read the message and any `fix`; check permissions and workspace access. |
| [`409 no_eligible_route`](https://boxrouter.ai/docs/error-codes#no_eligible_route) | No route meets the request. Nothing was created. | Read `fix` and `details.nearest`; see [routing](https://boxrouter.ai/docs/routing.md). |
| [`409 not_named`](https://boxrouter.ai/docs/error-codes#not_named) | The sandbox's request did not name the capability this call uses. | Name it in `requirements` on the next sandbox. |
| [`402 insufficient_credit`](https://boxrouter.ai/docs/error-codes#insufficient_credit) | Your available credit is below the amount BoxRouter must reserve before work starts. | Buy Credits or ask for a shorter `lifetime_seconds`. |
| [`402 spend_limit_exceeded`](https://boxrouter.ai/docs/error-codes#spend_limit_exceeded) | The request would take you past a spend limit you set. | Raise the limit or wait for its period to reset. |
| [`429 rate_limited`](https://boxrouter.ai/docs/error-codes#rate_limited) | Your workspace sent too many requests. The request did nothing. | The SDKs and CLI resend it after `retry_after_seconds`. If it still fails, send fewer requests at once. See [limits](https://boxrouter.ai/docs/limits.md#limits-your-workspace-shares). |
| [`429 provider_quota`](https://boxrouter.ai/docs/error-codes#provider_quota) | Your provider account is over its plan or quota limit. `native_message` has the provider's words. | Free capacity or raise the limit there. See [limits](https://boxrouter.ai/docs/limits.md#limits-your-provider-account-sets). |
| [`504 timeout`](https://boxrouter.ai/docs/error-codes#timeout) | BoxRouter lost the command's answer, so `retry` is `check_outcome`. The command may still be running. A command that only reached `timeout_seconds` is a result with `timed_out` set to `true`. | Read the operation; raise `timeout_seconds` or use a [process](https://boxrouter.ai/docs/processes.md). |
| [`410 sandbox_ended`](https://boxrouter.ai/docs/error-codes#sandbox_ended) | The sandbox was destroyed or its lifetime ran out. | Check the command outcome and effects before repeating work in a new sandbox. |
| [`403 authorization_error`](https://boxrouter.ai/docs/error-codes#authorization_error) on a file | The path is not writable by the user that file calls run as. | Use a writable path such as `/tmp`. |
| [`502 provider_error`](https://boxrouter.ai/docs/error-codes#provider_error) | The provider could not be reached or failed the operation. | Check the operation and effects before retrying or switching providers. |

Handle a code you do not know by its `retry` value, because new codes can be added.

## Keep the Diagnostic Identifiers

Every HTTP error has a `request_id`. Keep the `operation_id` when the error includes it, and save
the idempotency key you sent with the request. Provider failures can include `provider`,
`native_status` and `native_code`; a failed create can list attempted routes in `details.attempts`
([route result](https://boxrouter.ai/docs/routing.md#read-the-route-result)).

When available, the CLI prints these on stderr. Python's `boxrouter.api_error.APIError` exposes them in snake
case (`operation_id`), TypeScript's `APIError` in camel case (`operationId`), and both keep the whole error
body in `error`. Send them when you ask for help, and keep API keys, provider credentials, and workload data
out of the report.

## When Cleanup Fails

Leaving a Python `with` block or TypeScript `finally` requests destruction; it does not confirm it.
If the sandbox reads `destroying`, BoxRouter finishes the destroy without you; to see the result,
[inspect the operation](#inspect-an-uncertain-operation) before retrying.

With the ID, [check its state](https://boxrouter.ai/docs/reference/get.md). If it is `destroying`, check again. If it is running
or paused and no deletion is pending, follow the [destroy instructions](https://boxrouter.ai/docs/sandboxes.md#destroy-it).

In Python, the cleanup error carries the sandbox ID. If your code also failed, a
`BaseExceptionGroup` holds both errors. In TypeScript, a `finally` error replaces the earlier one,
so catch them separately when both matter.

Pressing Ctrl-C during `boxrouter run` requests destruction and prints the `boxrouter get` command that confirms it.

## Next Steps

> [card] [Error Codes](https://boxrouter.ai/docs/error-codes.md)
>
> Every code, whether work started, and what to do.

> [card] [Check Your Limits](https://boxrouter.ai/docs/limits.md)
>
> Which limits apply and who can change them.

> [card] [Create a Sandbox](https://boxrouter.ai/docs/sandboxes.md)
>
> Destroy what you create so a failure leaves nothing behind.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.