# Error handling

Triage errors with one instanceof check, retry safely, and handle MFA step-up flows.

Every SDK error extends `PolyesterError`, with a stable `code` and a `retryable` flag. The tree splits on retryability, so triage is one `instanceof`:

```ts
import { PolyesterError, TransientError, RateLimitError } from "@polyester/sdk";

await client.catalog.ensureReady();
const input = {
	symbolId: client.catalog.market.requireSymbolIdByPairSymbol("BTC-USDT"),
	side: "buy" as const,
	qty: "0.001",
	execution: { type: "limit_gtc" as const, price: "10000", postOnly: true },
	clientOrderId: crypto.randomUUID(),
};

try {
	await client.orders.create(input);
} catch (err) {
	if (err instanceof RateLimitError) {
		console.log(err.rateLimit?.remaining); // exact quota values are decimal strings
		await sleep(err.retryAfterMs ?? 1000);
		// reconcile this create by clientOrderId before resubmitting
	} else if (err instanceof TransientError) {
		// outcome may be unknown; reconcile before resubmitting
	} else if (err instanceof PolyesterError) {
		console.error(err.code, err.message); // permanent: fix the input or auth
	} else {
		throw err;
	}
}
```

Full tree: [Errors reference](https://testnet.polyester.com/docs/sdk/typescript/reference/errors). Short version:

- `TransientError` (retryable): `NetworkError`, `TimeoutError`, `RateLimitError`, `ServiceUnavailableError`
- `RequestError` (permanent): `ValidationError` (includes `PolicyScopeMismatchError`), `ResourceNotFoundError`, `NotImplementedError`, `AlreadyExistsError`, `PermissionError`, `AuthenticationError`, `PreconditionFailedError` (includes `RevisionConflictError`, `PolicyInUseError`, `PolicyLockedError`, `SubaccountChallengeInvalidError`), `ConfigurationError`, `MfaRequiredError` (and subclasses), `MfaVerificationError`
- `InternalServerError`: backend failure; not auto-retryable

RPC failures keep the original ConnectRPC error as `err.cause`.

## Retrying safely

Transient failures may have happened after the backend applied a mutation. A single-order create must be reconciled by `clientOrderId` before resubmission. Reusing a retained client order ID returns `CONFLICT_DUPLICATE_CLIENT_ORDER_ID`; it does not replay the earlier result. For a mutation with a replayable `requestId`, follow that operation's retry contract and reuse the request ID.

The helper below is suitable for reads and for mutations whose endpoint documents a replayable request ID. Do not wrap `orders.create` in it without a reconciliation step:

```ts
import { isRetryableError } from "@polyester/sdk";

async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
	for (let i = 0; ; i++) {
		try {
			return await fn();
		} catch (err) {
			if (i >= attempts - 1 || !isRetryableError(err)) throw err;
			await sleep(500 * 2 ** i);
		}
	}
}
```

`orders.modify` and `orders.cancelAll` accept a `requestId` for the same purpose.

## Revision conflicts

Address book entries, API keys, subaccounts, and their policies use optimistic concurrency. Updates (and some deletes) take `expectedRevision` from the entity you last read. If another writer got there first, you get `RevisionConflictError`.

Do not retry the same payload. Refetch, reconcile the draft against the latest values, then submit with the new `revision`.

```ts
import { RevisionConflictError, isRevisionConflictError } from "@polyester/sdk";

try {
	await client.apiKeys.update({
		keyId: key.keyId,
		expectedRevision: key.revision,
		label: "rotated bot",
	});
} catch (err) {
	if (err instanceof RevisionConflictError || isRevisionConflictError(err)) {
		const latest = await client.apiKeys.get({ keyId: key.keyId });
		// show latest to the user, then retry with latest.revision
	} else {
		throw err;
	}
}
```

## Cancellation

Every request method accepts an `AbortSignal`:

```ts
const controller = new AbortController();
const promise = client.marketOverview.list({}, { signal: controller.signal });
controller.abort();
```

Aborts are not `PolyesterError`s. Detect caller-initiated cancellations with `isAbortError(err)`, treat them as flow control, and do not retry them. Transport cancellations are normalized only when the caller's signal is aborted; a server cancellation alone is not a caller abort. A signal that is already aborted prevents the request from starting. `formatUserFacingError(err, fallback)` returns `"Request canceled."` for a cancellation.

## MFA: step-up and elevation

Sensitive mutations can require a second factor:

| Error                           | Meaning                           | Your move                                 |
| ------------------------------- | --------------------------------- | ----------------------------------------- |
| `MfaEnrollmentRequiredError`    | User has no MFA factor            | Send them through enrollment              |
| `StepUpRequiredError`           | Needs a fresh one-use proof       | Run a challenge, retry with `stepUpToken` |
| `SessionElevationRequiredError` | Needs a recently elevated session | Run an elevation challenge, retry         |

Step-up flow:

```ts
import { StepUpRequiredError } from "@polyester/sdk";

try {
	await client.apiKeys.create(payload);
} catch (err) {
	if (err instanceof StepUpRequiredError) {
		const challenge = await client.mfa.beginChallenge({ purpose: "freshStepUp" });
		const { stepUpToken } = await client.mfa.verifyTotpChallenge({
			challengeId: challenge.challengeId,
			code: userEnteredCode,
		});
		await client.apiKeys.create(payload, { stepUpToken });
	} else {
		throw err;
	}
}
```

Catch `MfaRequiredError` for all three cases in one branch. Guards for unknown errors: `isFreshStepUpRequiredError`, `isSessionElevationRequiredError`, `isMfaEnrollmentRequiredError`.

## Validation and precision errors

Client-side validation throws before any network I/O:

```ts
import {
	CatalogConversionError,
	CatalogLookupError,
	CatalogNotReadyError,
	CatalogValidationFailedError,
} from "@polyester/sdk/catalogs";
```

- `ValidationError`: wrong shape (unknown keys, missing fields, bad input enums)
- A newer backend enum value in a read response decodes as `"unspecified"` rather than rejecting the whole response. Treat that value as an unknown state and keep the rest of the payload usable.
- `CatalogConversionError`: decimal string is invalid, has excess precision, or exceeds its protobuf wire-format ceiling (`"0.1234567"` on a 6-decimal field). Order and trigger methods throw before network I/O. The wire ceiling is not an exchange limit.
- `CatalogValidationFailedError`: order input breaks pair constraints (tick, step, min qty, min notional) via `catalog.orders.assertSpotOrderDecimalInput`
- `CatalogLookupError` / `CatalogNotReadyError`: unknown symbol/asset, or a direct catalog read before `ensureReady()`

> **Validate before submit**
>
> For form UX, run `client.catalog.orders.validateSpotOrderDecimalInput(...)` for a structured list of parse, tick, step, and minimum violations instead of catching a thrown error. The validator does not enforce the protobuf wire ceilings.

## Streaming errors

With `onError`, subscription failures arrive as `SdkSubscriptionErrorContext` (`channel`, `type`, `error`). A private subscription without usable auth throws when `onError` is omitted. Reconnects are automatic for transient failures. Non-retryable SDK token errors stop automatic retries and reach `onError` unchanged; correct the auth or inputs before explicitly subscribing again. Snapshot-then-stream subscriptions refetch after reconnect or an observed sequence gap; they do not detect a connected feed that goes quiet. Entity streams do not promise replay. See [Streaming](https://testnet.polyester.com/docs/sdk/typescript/guides/streaming).
