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.statuserr.body?.typeerr.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.
Related
- API errors — the full problem-type taxonomy.
- SDK configuration — how the singleton picks up the refreshed token.