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

# Errors and retries

> Branch on typed error codes, and understand which calls the SDK retries, which it never repeats, and where the idempotency key comes from.

Every failure `@endstate-sdk/core` raises is a typed error carrying the
information support needs. Retry behaviour is not guesswork either: it is
derived from the API spec at build time, so a call is retried only when
repeating it is safe.

## Branch on the code

```ts theme={null}
import { isEndstateError, isEndstateApiError } from "@endstate-sdk/core/errors";

try {
  await endstate.chips.pair({ unit_id, chip_id, e, c });
} catch (error) {
  if (isEndstateApiError(error)) {
    error.code; // "chip.already_scanned"
    error.status; // 409
    error.requestId; // quote this to support
    error.docUrl; // link to the error's documentation
    error.retryAdvice; // "never" | "retry" | "wait_and_retry" | "poll_resource"
    error.details; // validation failures, when the code carries them
  }
  if (isEndstateError(error)) {
    error.attempts; // greater than 1 means the SDK retried
  }
}
```

<Warning>
  Branch on `error.code`, never on `message` or the HTTP status. Several codes
  share one status. Codes are added over time, so treat an unrecognized one as a
  generic failure rather than throwing.
</Warning>

Narrow to specific codes with `isEndstateError(error, ...codes)`:

```ts theme={null}
if (isEndstateError(error, "chip.already_scanned", "chip.invalid_e_value")) {
  // The tap credential was spent or malformed - ask for a fresh tap.
}
```

## The error types

| Type                         | When                                                  |
| ---------------------------- | ----------------------------------------------------- |
| `EndstateApiError`           | The API replied with an error envelope                |
| `EndstateHttpError`          | A non-2xx that carried no envelope                    |
| `EndstateNetworkError`       | The request never completed                           |
| `EndstateTimeoutError`       | Your `timeoutMs` or `maxElapsedMs` elapsed            |
| `EndstateTerminalStateError` | A `waitUntil` helper found a settled failure          |
| `EndstateConfigError`        | A credential was missing or wrong-prefixed at startup |
| `ChipUrlError`               | The input was not a tap URL                           |

All extend `EndstateError`, which carries `requestId`, `operationId`, and
`attempts`. Log `requestId` on every failure - it is the fastest way for
support to find your request.

## Which calls are retried

Retry eligibility comes from the spec, not from the method name. Every
operation falls into one of three classes.

| Class     | Calls                                                                | Retried on                                                     |
| --------- | -------------------------------------------------------------------- | -------------------------------------------------------------- |
| **Read**  | Every `GET`                                                          | Network failure, timeout, `429`, `5xx`                         |
| **Keyed** | Creates that accept an [`Idempotency-Key`](/conventions/idempotency) | Network failure, timeout, `429`, `409 idempotency.in_progress` |
| **Write** | Updates and other mutations that accept no key                       | `429` only                                                     |

Two consequences worth internalizing:

**A keyed write is never retried on a `5xx`.** The API clears its idempotency
record on any non-2xx, so a same-key retry would execute a second time instead
of replaying the first. The SDK will not do that for you.

**Writes are never repeated at all.** `units.update`,
`collections.update`, `settings.update`, `settings.corsOrigins.replace`,
`sessionTokens.revoke`, and `testHelpers.createTap` accept no idempotency key,
so the API cannot deduplicate them and a second send is a second write.
Raising `maxAttempts` does not change this, and that is not a bug. The single
exception is `429 rate_limit.exceeded`, which the API refuses before any
handler runs, so nothing was written.

## Where the idempotency key comes from

For a keyed create, the SDK sends an `Idempotency-Key` on your behalf. Which
key it sends determines whether a *later run* is safe, and `error.safeToRetry`
tells you which case you are in.

```ts theme={null}
// You supply the key - stable across runs. safeToRetry is true.
await endstate.units.create(body, { idempotencyKey: "jacket-0001" });

// The SDK generates one - stable across this call's retries only.
await endstate.units.create(body);
```

This matters when a call fails without an answer. `EndstateNetworkError` and
`EndstateTimeoutError` - the two cases where you cannot know whether the write
landed - carry `safeToRetry` and the `idempotencyKey` that was used.

`safeToRetry` means "calling again recovers this same operation". It is true
for a read, and for a write whose key is stable across calls - one you
supplied, or the one `verify()` derives from the tap. It is **false** when the
SDK generated the key, because a fresh call would generate a different one and
the API would treat it as a second write.

Pass the key back to recover the original:

```ts theme={null}
import {
  EndstateNetworkError,
  EndstateTimeoutError,
} from "@endstate-sdk/core/errors";

try {
  await endstate.units.create(body);
} catch (error) {
  const unresolved =
    error instanceof EndstateNetworkError ||
    error instanceof EndstateTimeoutError;

  if (unresolved && !error.safeToRetry && error.idempotencyKey) {
    // Same key, so this replays the original instead of creating a second unit.
    await endstate.units.create(body, { idempotencyKey: error.idempotencyKey });
  }
}
```

<Tip>
  When the same logical create can be issued by a later run - a nightly sync, a
  job that may be replayed - **supply your own key**. The resource's
  `external_id` or a job id is usually the right choice, so the second run
  replays the first response instead of creating a second resource.
</Tip>

## Tuning it

Retries use equal-jitter exponential backoff and honour `Retry-After`.
Configure per client or per call:

```ts theme={null}
new EndstateClient({
  apiKey,
  timeoutMs: 15_000,
  retry: { maxAttempts: 5, baseDelayMs: 500 },
});

await endstate.units.get(id, { retry: false, signal: controller.signal });
```

`DEFAULT_RETRY_POLICY` is what applies when you set none - spread it to change
one field without restating the rest:

```ts theme={null}
import { DEFAULT_RETRY_POLICY } from "@endstate-sdk/core";

new EndstateClient({
  apiKey,
  retry: { ...DEFAULT_RETRY_POLICY, maxAttempts: 5 },
});
```

Observe what is happening with `onRequest`, `onResponse`, and `onRetry`:

```ts theme={null}
new EndstateClient({
  apiKey,
  onRetry: ({ attempt, reason, delayMs }) => {
    logger.warn({ attempt, reason, delayMs }, "retrying");
  },
});
```

`reason` is one of `network`, `timeout`, `rate_limited`, `server_error`, or
`idempotency_in_progress`.
