Error Handling
A short reference on the error envelope is in Errors. This guide goes deeper: which requests are safe to retry, and what a production-grade handler looks like.
The error envelope#
Always branch on detail.code, never on the human-readable message (messages may change; codes are stable). Group codes by how your client should react:
- Fix the credential —
INVALID_TOKEN,TOKEN_EXPIRED,MISSING_AUTH_HEADER. Don't retry; surface to the operator. - Fix the request —
INSUFFICIENT_SCOPE(use a read_write token),422validation. Don't retry unchanged. - Top up —
insufficient_balance(402). Don't retry until credits are added. - Transient —
429and5xx. Retry with backoff.
Idempotency & retries#
Only retry requests that are safe to repeat. The key distinction is whether a duplicate has a side effect:
| Request | Safe to retry? | Why |
|---|---|---|
GET /companies/{id} | Yes | Read-only. |
POST /companies/search | Yes | Read-only despite the POST verb. |
GET /credits | Yes | Read-only. |
GET /exports/{id} | Yes | Read-only status poll. |
POST /exports/{id}/download | Yes | Generates a fresh presigned URL; no charge. |
POST /exports | With care | Creates a job + charges credits. A blind retry can create a second export. Only retry on a network error where you never received a response; on a 5xx, check GET /exports first. |
A production error handler#
This handler classifies the error, retries only transient failures with jittered backoff, and refuses to retry charged operations blindly:
async function request(url, init, { retryable = true, max = 4 } = {}) {
for (let attempt = 0; ; attempt++) {
let res;
try {
res = await fetch(url, init);
} catch (networkErr) {
// No response received — safe to retry even charged calls.
if (attempt >= max) throw networkErr;
await sleep(backoff(attempt));
continue;
}
if (res.ok) return res.json();
const { detail = {} } = await res.json().catch(() => ({}));
const transient = res.status === 429 || res.status >= 500;
if (transient && retryable && attempt < max) {
const ra = Number(res.headers.get("Retry-After"));
await sleep(ra ? ra * 1000 : backoff(attempt));
continue;
}
throw Object.assign(new Error(detail.message || `HTTP ${res.status}`), {
code: detail.code, status: res.status,
});
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const backoff = (n) => (2 ** n * 1000) * (1 + Math.random() * 0.3);Call POST /exports with retryable: false so a 5xx doesn't silently create a duplicate charged job.