Error handling
The typed error hierarchy — ApiError, AuthError, BillingError, SessionNotReadyError, the RUNTIME_UNAVAILABLE handshake — and how to catch, branch on, and format them for the UI.
Every REST call made through createKortix(...) or backendApi rejects with a
real Error subclass — never a plain object. catch it, instanceof it, and
branch on .status/.code. These are the same classes on every host: a
server-side "Kortix as a backend" wrapper and the React UI both import from the
one module.
import { ApiError, BillingError } from '@kortix/sdk';
try {
await kortix.project(projectId).sessions.create();
} catch (err) {
if (err instanceof BillingError) {
// 402 — out of credits / plan limit
} else if (err instanceof ApiError) {
// any other failed request — err.status, err.code, err.detail
} else {
throw err;
}
}The classes
| class | extends | when | key fields |
|---|---|---|---|
ApiError | Error | the default for any failed request — bad status, network failure, timeout, or an aborted request | status, code, details/data, detail, response, url, endpoint, timeout |
AuthError | ApiError | getToken returned null — the request was never sent | code is always 'NO_SESSION' |
BillingError | Error | HTTP 402 — the backend's only billing error today | status (402), detail: { message, ... } |
RequestTooLargeError | Error | HTTP 431 — typically too many files attached to one request | status (431), detail: { message, suggestion } |
SessionNotReadyError | Error | a runtime accessor (previewUrl, proxyUrl, .runtime) was called before ensureReady() — see below | name is 'SessionNotReadyError' |
BillingError used to be an 8-class hierarchy (AGENT_RUN_LIMIT_EXCEEDED,
THREAD_LIMIT_EXCEEDED, etc.). The backend now only ever returns a plain 402 with {message},
so it's one class — legacy code that still switches on old error-code strings can delete that
branch.
ApiError
The class every failed backendApi/facade call produces. name defaults to
'ApiError' but is overridden for specific failure modes you can match on:
name: 'AbortError',code: 'ABORTED'— the request was aborted externally (navigation, React Query cancellation) — not a timeout, safe to ignore.code: 'TIMEOUT'— the request's own timeout budget elapsed;url,endpoint, andtimeout(ms) are set so you know what timed out.- Otherwise
statusis the HTTP status code andcodeis the backend'serror_code/detail.error_code(falling back to the status as a string) when the body carried one.
Transient gateway failures are retried before you see them: idempotent reads
(GET/HEAD) that return 502, 503, or 504 retry up to two times with a
250ms → 500ms backoff. A single proxy/ALB blip is absorbed silently and never
reaches onError; persistent failures surface as a normal ApiError with the
last status. Mutations (POST/PUT/PATCH/DELETE) and deterministic 500s
are never retried.
message is an enumerable own property (not just inherited from Error),
so spreading the error or JSON.stringify-logging it includes the message —
useful for error reporting.
AuthError
Thrown client-side, before any request goes out, when getToken() resolves to
null — there's no way to sign the request. It's an ApiError subclass, so
err instanceof ApiError still matches; check err instanceof AuthError (or
err.code === 'NO_SESSION') to special-case "not signed in" from a real
backend failure.
BillingError
Thrown for HTTP 402 responses — out of credits, over a plan limit, or any
other billing gate. detail.message is the human-readable reason from the
backend; detail may carry additional fields the backend chose to include.
RequestTooLargeError
Thrown for HTTP 431 (Request Header Fields Too Large) — in practice, this
means too many files were attached to a single request. detail.suggestion
is a ready-to-show hint ("Try uploading files one at a time...").
Session readiness errors
Two failure modes are about the session's runtime not being there yet — they're deliberate design, not bugs, and each has one correct response.
SessionNotReadyError
Thrown synchronously, client-side when you touch a runtime-scoped accessor
— session.previewUrl(), session.proxyUrl(), session.runtime — before this
handle has resolved its own runtime. This enforces the SDK's most important
invariant: a session handle resolves its own sandbox or throws — it never
silently falls back to whatever sandbox happens to be globally active (which
could route your calls into another session's machine).
import { SessionNotReadyError } from '@kortix/sdk';
const s = kortix.session(projectId, sessionId);
try {
const url = s.previewUrl(3000); // ← throws: nothing resolved yet
} catch (err) {
if (err instanceof SessionNotReadyError) {
await s.ensureReady(); // resolve the runtime first
}
}The fix is always the same: await session.ensureReady() (or send(), which
readies internally) before the accessor. session.health() is the one
exception — it never throws this, so a health poller can run before the
session has ever booted.
ApiError with code: 'RUNTIME_UNAVAILABLE'
Thrown by ensureReady() when its single bounded /start long-poll returns
while the sandbox is still provisioning — normal on a cold boot, which can
take longer than one poll. It means not ready yet, not failed: retry, and
each retry re-attaches to the same server-side provision.
if (err instanceof ApiError && err.code === 'RUNTIME_UNAVAILABLE') {
// still provisioning — wait a few seconds and call ensureReady() again
}The full retry-with-deadline pattern lives on the
Streaming
page; in React, useSession handles all of this for you (surfacing it as the
boot phase instead of an exception).
One instanceof trap: if a page somehow loads two copies of the SDK (the
ESM build and the CDN window.Kortix global), each copy has its own
ApiError class and instanceof across the boundary silently returns
false. Pick one distribution per page — see
Distribution.
Helpers
| helper | signature | what |
|---|---|---|
parseBillingError(error) | (error: any) => Error | If error's status is 402, wraps it into a BillingError; otherwise returns error unchanged. Called internally on every 402 response, so callers rarely need it directly. |
isBillingError(error) | (error: any) => boolean | error instanceof BillingError |
formatBillingErrorForUI(error) | (error: any) => BillingErrorUI | null | Returns null for anything that isn't a BillingError; otherwise returns { alertTitle, alertSubtitle } ready to render in an upgrade/pricing modal — with a dedicated "you ran out of credits" copy when the message mentions credits/balance/insufficient. |
import { formatBillingErrorForUI } from '@kortix/sdk';
try {
await kortix.session(projectId, sessionId).start();
} catch (err) {
const ui = formatBillingErrorForUI(err);
if (ui) showUpgradeModal(ui.alertTitle, ui.alertSubtitle);
}In @kortix/sdk/react
@kortix/sdk/react re-exports BillingError, RequestTooLargeError,
parseBillingError, isBillingError, and formatBillingErrorForUI — not
ApiError/AuthError, which stay root-only (@kortix/sdk) since they're the
generic REST-failure shape, not something a chat UI branches on. Import both
from wherever you already import @kortix/sdk/@kortix/sdk/react — same
classes either way.
useSession's send/answerQuestion/answerPermission/rejectQuestion
classify every failure into a KortixSendError (sendError on the hook's
return value) instead of making you instanceof-check by hand:
interface KortixSendError {
kind: 'billing' | 'runtime-not-ready' | 'runtime-error';
message: string; // already formatted for display
billing?: BillingError; // present when kind === 'billing'
cause: unknown; // the original thrown value
}const s = useSession(projectId, sessionId);
if (s.sendError?.kind === 'billing') {
const ui = formatBillingErrorForUI(s.sendError.billing);
// ...
}See React hooks for the rest of useSession's surface.