Error handling
Branch on BastionError subclasses, retry only what's retriable, and surface useful messages to users.
Three rules cover 95% of cases:
- Branch on
instanceof, never onnameormessage. - Retry only
RateLimitError,UpstreamError, andAPIConnectionError. Everything else is a bug or a permanent failure. - Don't swallow
BastionError— logerr.status,err.code, and a redacted view oferr.bodyso 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 redactederr.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.