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

StatusMeaningAction
400Malformed requestFix the request; do not retry unchanged
401Missing or invalid credentialCheck the header and the key
402Payment required — two distinct casesSee below
403Authenticated but not permittedGrant the permission or use a different key
404Not found, or not visible in your scopeSee below
409Conflict with current stateResolve the conflict; do not blind-retry
415Unsupported content typeUse /uploads for binaries
422Validation failedFix the payload
429Rate limitedBack off; honour Retry-After
503Dependency unavailableRetry with backoff

The two 402s

These look identical and need opposite responses:

codeMeaningFix
upgrade_requiredYour plan does not permit thisChange plan. A top-up will not help
insufficient_creditsYour balance is too lowTop 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

StatusRetry?
429Yes — honour Retry-After
503Yes — exponential backoff
500Yes, 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));
  }
}
Was this page helpful?
Report an issue

On this page