Qubittron Bastion
TypeScript SDKGuides

Error handling

Branch on BastionError subclasses, retry only what's retriable, and surface useful messages to users.

Three rules cover 95% of cases:

  1. Branch on instanceof, never on name or message.
  2. Retry only RateLimitError, UpstreamError, and APIConnectionError. Everything else is a bug or a permanent failure.
  3. Don't swallow BastionError — log err.status, err.code, and a redacted view of err.body so on-call has something to grep.

Minimum viable handler

import {
  Bastion,
  AuthenticationError,
  BadRequestError,
  PermissionDeniedError,
  RateLimitError,
  UpstreamError,
  APIConnectionError,
  BastionError,
} from "@qubittron/bastion-sdk";

const client = new Bastion();

try {
  const res = await client.chat.completions.create({ model, messages });
  return res;
} catch (err) {
  if (err instanceof RateLimitError) return userFriendly("We're being rate-limited — try again in a moment.");
  if (err instanceof AuthenticationError) return userFriendly("API key invalid — contact support.");
  if (err instanceof PermissionDeniedError) return userFriendly("Quota exhausted or content blocked.");
  if (err instanceof BadRequestError) {
    log.error({ status: err.status, code: err.code, body: err.body }, "bad request to Bastion");
    return userFriendly("Internal error.");
  }
  if (err instanceof UpstreamError) return userFriendly("Upstream provider is down — try a different model.");
  if (err instanceof APIConnectionError) return userFriendly("Network glitch — try again.");
  if (err instanceof BastionError) {
    log.error({ status: err.status, code: err.code, body: err.body }, "unexpected Bastion error");
    return userFriendly("Internal error.");
  }
  throw err; // not from the SDK
}

Retries

The SDK does not retry automatically (as of 0.1). Compose a simple exponential-backoff wrapper:

async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
  let attempt = 0;
  while (true) {
    try {
      return await fn();
    } catch (err) {
      attempt += 1;
      const retriable =
        err instanceof RateLimitError ||
        err instanceof UpstreamError ||
        err instanceof APIConnectionError;
      if (!retriable || attempt >= max) throw err;

      const base = 2 ** attempt * 250;
      const jitter = Math.random() * 250;
      await new Promise((r) => setTimeout(r, base + jitter));
    }
  }
}

await withRetry(() => client.chat.completions.create({ model, messages }));

Tune max and the backoff curve to your latency budget. Do not retry BadRequestError or AuthenticationError — those are deterministic and will fail again.

Streaming errors

API errors throw at the initial create() call, before iteration starts:

let stream;
try {
  stream = await client.chat.completions.create({ model, messages, stream: true });
} catch (err) {
  // 4xx / 5xx — handle here, just like a non-streaming call
}

try {
  for await (const chunk of stream) render(chunk);
} catch (err) {
  // Mid-stream failures are network-level — APIConnectionError or AbortError
}

Wrap each phase separately; the failure modes differ and so should your handlers.

Surfacing to users

err.message is the upstream message — often useful to a developer, sometimes confusing to an end user. For user-facing surfaces:

  • Show a generic, action-oriented message ("Try again", "Contact support").
  • Log the full error with err.status, err.code, err.type, and a redacted err.body.
  • Include a request id from your own infra so on-call can find the trace.
log.error(
  {
    bastion: {
      status: err.status,
      code: err.code,
      type: err.type,
    },
    requestId,
  },
  err.message,
);

Logging err.body

err.body is the parsed JSON body (or the raw string if non-JSON). Upstreams sometimes echo the offending request fragment in error.message — be deliberate about what you log. A safe default: log status, code, type, and the first 500 chars of a stringified body. Don't log Authorization headers or full request bodies that may contain user content.

On this page