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,TimestampSkewError,ServiceUnavailableErrorRequestError(permanent):ValidationError(includesStaleQuoteError,WithdrawDeadlineExpiredError,PolicyScopeMismatchError),ResourceNotFoundError,NotImplementedError,AlreadyExistsError,PermissionError,AuthenticationError,PreconditionFailedError(includesRevisionConflictError,PolicyInUseError,PolicyLockedError,SubaccountChallengeInvalidError),ConfigurationError,MfaRequiredError(and subclasses),MfaVerificationError,OutcomeUnknownError, and the smart-account UserOperation errorsInternalServerError: backend failure; not auto-retryableRealtimeError: realtime subscription failures;retryablevaries
RPC failures keep the original ConnectRPC error as err.cause, and errors mapped from a failed
response carry its HTTP status.
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.
OutcomeUnknownError is deliberately not retryable: an order or trigger change was sent but not
confirmed in time, so it may already have applied. detail.code names the operation, such as ORDER_CREATE_OUTCOME_UNKNOWN. Read the order by its client order ID, or list triggers by client
trigger ID, before sending it again.
An orders OVERLOADED detail arrives as a RateLimitError without retryAfterMs: the account
already has its maximum order requests in flight. Retry after an outstanding request completes.
TimestampSkewError means an API-key request's signed timestamp fell outside the backend's skew
window. Retrying signs it again. It usually comes from large bursts that wait in the runtime's
fetch queue, so bound concurrency instead of firing every request at once.
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 and timeouts
Every request method accepts an AbortSignal and a timeoutMs deadline:
const controller = new AbortController();
const promise = client.marketOverview.list({}, { signal: controller.signal });
controller.abort();
await client.marketOverview.list({}, { timeoutMs: 5_000 }); // TimeoutError after 5 sAborts 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.
Deadlines reject with the retryable TimeoutError: timeoutMs (a positive integer of at most 2147483647, also sent to the backend as the Connect deadline), or an AbortSignal.timeout() signal that expires during the call, whether the caller or an interceptor supplied it. A caller
signal that had already expired before the call started rejects as a non-retryable abort, so retry
loops stop instead of spinning on it. isTimeoutAbortError(err) detects the raw AbortSignal.timeout() exception outside SDK calls.
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). The message names the failing field path, such asitems.0.symbolId: Invalid key: ...fororders.batchCreate; when an input matches none of a method's accepted shapes, it comes from the closest one.- 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), where error is always an Error. Server and WebSocket failures are RealtimeErrors
with the Centrifugo code on realtimeCode; skip reporting those with retryable: true, which the
realtime client is already retrying (for example code 109, token expired). 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 other than orders.subscribe do not promise replay.
See Streaming.