Error handling

ApiError, the 401-refresh-retry pattern, rate-limit backoff, and graceful degradation.

The SDK throws an ApiError on any non-2xx response. Its body is the RFC 7807 problem, so client logic switches on the stable type field rather than parsing free-form text.

The shape of ApiError

class ApiError extends Error {
url: string;
status: number;
statusText: string;
body: Problem | unknown; // RFC 7807 problem in practice
request: { method: string; url: string; ... };
}

Always type-narrow before reading body:

import { ApiError } from "@citefoundry/api-client";
try {
// ...
} catch (err) {
if (!(err instanceof ApiError)) throw err;
// err.body is now safely typed
}

The 401-refresh-retry pattern

For dashboard-style clients where the token can expire mid-session, wrap calls in a refresh-on-401 helper:

import { ApiError, DefaultService, OpenAPI } from "@citefoundry/api-client";
import { refreshSession } from "./auth";
async function withRefresh<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (err) {
if (
err instanceof ApiError &&
err.status === 401 &&
err.body?.type === "https://citefoundry.com/problems/unauthorized"
) {
const next = await refreshSession();
OpenAPI.TOKEN = next.accessToken;
return fn(); // retry once
}
throw err;
}
}
// Usage:
const runs = await withRefresh(() =>
DefaultService.listRuns({ projectId, limit: 100 }),
);

Only retry once. If the second call also throws 401, sign the user out — the refresh itself failed.

Rate-limit backoff

rate-limited problems include a retryAfter field (seconds):

async function withRateLimit<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
if (
err instanceof ApiError &&
err.body?.type === "https://citefoundry.com/problems/rate-limited" &&
i < attempts - 1
) {
const seconds = err.body?.retryAfter ?? 1;
await new Promise((r) => setTimeout(r, seconds * 1000));
continue;
}
throw err;
}
}
throw new Error("unreachable");
}

If you’re consistently hitting rate limits, talk to us about your plan’s cap — it’s usually faster to negotiate than to engineer around.

Validation errors

validation problems carry an errors array, one per invalid field. Surface them inline:

try {
await DefaultService.createMonitor({ projectId, requestBody });
} catch (err) {
if (
err instanceof ApiError &&
err.body?.type === "https://citefoundry.com/problems/validation"
) {
for (const fieldErr of err.body.errors ?? []) {
formFieldError(fieldErr.path, fieldErr.message);
}
return;
}
throw err;
}

Plan-quota and upgrade prompts

plan-quota-exceeded is the right place to show an upgrade CTA rather than a generic error:

try {
await DefaultService.createMonitor({ projectId, requestBody });
} catch (err) {
if (
err instanceof ApiError &&
err.body?.type === "https://citefoundry.com/problems/plan-quota-exceeded"
) {
showUpgradeDialog({
limit: err.body.quotaLimit,
used: err.body.quotaUsed,
});
return;
}
throw err;
}

Composing the wrappers

In the dashboard, calls are wrapped in withRefresh(withRateLimit(...)) at a single chokepoint. Don’t sprinkle try/catch through every call site — handle a small set of specific type values centrally and let everything else bubble.

What to log

For monitoring, log:

  • err.status
  • err.body?.type
  • err.url + err.request.method
  • Your own correlation ID (not the user’s token)

Never log err.body.detail to user-facing error toasts as-is — it’s human-readable but may include identifiers a user doesn’t have context for. Use title for the toast and put detail in the dev console.