Errors
Status codes, the error shape, and what to do about each one.
Errors return a consistent shape:
{
"detail": "Plan limit reached for workspaces",
"code": "upgrade_required"
}detail is human-readable and may change. code is stable where present —
branch on code, never on detail.
Status codes
| Status | Meaning | Action |
|---|---|---|
400 | Malformed request | Fix the request; do not retry unchanged |
401 | Missing or invalid credential | Check the header and the key |
402 | Payment required — two distinct cases | See below |
403 | Authenticated but not permitted | Grant the permission or use a different key |
404 | Not found, or not visible in your scope | See below |
409 | Conflict with current state | Resolve the conflict; do not blind-retry |
415 | Unsupported content type | Use /uploads for binaries |
422 | Validation failed | Fix the payload |
429 | Rate limited | Back off; honour Retry-After |
503 | Dependency unavailable | Retry with backoff |
The two 402s
These look identical and need opposite responses:
code | Meaning | Fix |
|---|---|---|
upgrade_required | Your plan does not permit this | Change plan. A top-up will not help |
insufficient_credits | Your balance is too low | Top up |
if (response.status === 402) {
const { code } = await response.json();
if (code === 'upgrade_required') {
showUpgradePrompt(); // buying credits will not unblock this
} else {
showTopUpPrompt();
}
}Conflating them produces the worst possible outcome: a customer buys credits and is still blocked.
Why 404 and not 403
A read that crosses a tenant or scope boundary returns 404.
403 would confirm the resource exists, which over enough requests is an
enumeration oracle. 404 denies the existence signal along with the data. If you
are certain a resource exists and are getting 404, the usual causes are the
wrong tenant, the wrong workspace, or a scoped credential.
503 on ingestion
A 503 carrying CHORD_DLP_UNAVAILABLE means the perimeter scanner could not
run, so the content was not stored.
This is deliberate. Storing unscanned content would defeat the subsystem's entire purpose, so ingestion fails closed. Retry with backoff.
Retrying
| Status | Retry? |
|---|---|
429 | Yes — honour Retry-After |
503 | Yes — exponential backoff |
500 | Yes, once or twice |
4xx (others) | No — fix the request |
async function withRetry<T>(fn: () => Promise<Response>, max = 3): Promise<T> {
for (let attempt = 0; ; attempt++) {
const res = await fn();
if (res.ok) return res.json() as Promise<T>;
const retryable = res.status === 429 || res.status >= 500;
if (!retryable || attempt >= max) {
const { detail } = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(`Chordian ${res.status}: ${detail}`);
}
const after = Number(res.headers.get('retry-after'));
const delay = Number.isFinite(after) && after > 0
? after * 1000
: 2 ** attempt * 250 + Math.random() * 250; // jitter avoids retry storms
await new Promise((r) => setTimeout(r, delay));
}
}