Every SDK error extends PolyesterError, with a stable code and a retryable flag. The tree
splits on retryability, so triage is one instanceof:
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. Short version:
TransientError(retryable):NetworkError,TimeoutError,RateLimitError,ServiceUnavailableErrorRequestError(permanent):ValidationError(includesPolicyScopeMismatchError),ResourceNotFoundError,NotImplementedError,AlreadyExistsError,PermissionError,AuthenticationError,PreconditionFailedError(includesRevisionConflictError,PolicyInUseError,PolicyLockedError,SubaccountChallengeInvalidError),ConfigurationError,MfaRequiredError(and subclasses),MfaVerificationErrorInternalServerError: 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:
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.
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:
const controller = new AbortController();
const promise = client.marketOverview.list({}, { signal: controller.signal });
controller.abort();Aborts are not PolyesterErrors. 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:
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:
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) viacatalog.orders.assertSpotOrderDecimalInputCatalogLookupError/CatalogNotReadyError: unknown symbol/asset, or a direct catalog read beforeensureReady()
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.