Every error the SDK raises extends PolyesterError, which carries a stable machine-readable code and a retryable flag. The hierarchy splits on retryability, so triage is one instanceof:
import { PolyesterError, TransientError, RateLimitError } from "@polyester/sdk";
try {
await client.orders.create(input);
} catch (err) {
if (err instanceof RateLimitError) {
await sleep(err.retryAfterMs ?? 1000);
// retry with the same clientOrderId
} else if (err instanceof TransientError) {
// network / timeout / unavailable — safe to retry
} else if (err instanceof PolyesterError) {
console.error(err.code, err.message); // permanent — fix the input or auth
} else {
throw err;
}
}The full tree, with every class and code, is in the Errors reference. The shape in brief:
TransientError(retryable) →NetworkError,TimeoutError,RateLimitError,ServiceUnavailableErrorRequestError(permanent) →ValidationError,ResourceNotFoundError,AlreadyExistsError,PermissionError,AuthenticationError,PreconditionFailedError,ConfigurationError,MfaRequiredError(and its three subclasses)InternalServerError— backend failure; not automatically retryable
RPC failures keep the original ConnectRPC error as err.cause.
Retrying safely
Transient failures may have happened after the backend applied a mutation. Retry with the same idempotency key so the backend dedupes:
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);
}
}
}
const clientOrderId = crypto.randomUUID();
await withRetry(() => client.orders.create({ ...input, clientOrderId }));orders.modify and orders.cancelAll accept a requestId for the same purpose.
Cancellation
Every request method accepts an AbortSignal:
const controller = new AbortController();
const promise = client.marketOverview.list({}, { signal: controller.signal });
controller.abort();Aborts are deliberately not PolyesterErrors — detect them with isAbortError(err) and treat
them as flow control, not failures.
MFA: step-up and elevation
Sensitive mutations can require a second factor. The SDK signals this with dedicated error types:
| 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 |
The 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 to handle all three cases with one branch. Type-guard helpers
(isFreshStepUpRequiredError, isSessionElevationRequiredError, isMfaEnrollmentRequiredError) are exported for code that works with unknown errors.
Validation and precision errors
Client-side validation throws before any network I/O:
ValidationError— the input shape is wrong (unknown keys, missing fields, bad enum values).CatalogConversionError— a decimal string has excess precision or is not a valid decimal ("0.1234567"on a 6-decimal field). The SDK never rounds for you.CatalogValidationFailedError— order input violates pair constraints (tick size, step size, min quantity, min notional) when usingcatalog.orders.assertSpotOrderDecimalInput.CatalogLookupError/CatalogNotReadyError— unknown symbol/asset, or a direct catalog read beforeensureReady().
client.catalog.orders.validateSpotOrderDecimalInput(...) to get a
structured list of violations instead of catching a thrown error.Streaming errors
Subscriptions never throw — failures flow to your onError handler as an SdkSubscriptionErrorContext (channel, type, error). Reconnects are automatic;
snapshot-then-stream subscriptions also refetch their snapshot after a reconnect. See Streaming.