TypeScript SDKGithubEdit on GitHub

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

classextendswhenkey fields
ApiErrorErrorthe default for any failed request — bad status, network failure, timeout, or an aborted requeststatus, code, details/data, detail, response, url, endpoint, timeout
AuthErrorApiErrorgetToken returned null — the request was never sentcode is always 'NO_SESSION'
BillingErrorErrorHTTP 402 — the backend's only billing error todaystatus (402), detail: { message, ... }
RequestTooLargeErrorErrorHTTP 431 — typically too many files attached to one requeststatus (431), detail: { message, suggestion }
SessionNotReadyErrorErrora runtime accessor (previewUrl, proxyUrl, .runtime) was called before ensureReady() — see belowname 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, and timeout (ms) are set so you know what timed out.
  • Otherwise status is the HTTP status code and code is the backend's error_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

helpersignaturewhat
parseBillingError(error)(error: any) => ErrorIf 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) => booleanerror instanceof BillingError
formatBillingErrorForUI(error)(error: any) => BillingErrorUI | nullReturns 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 formatBillingErrorForUInot 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.

Error handling – Kortix Docs