Errors

Problem responses, the stable error codes, which ones are safe to retry and how to log them without leaking data.

Version v1 · updated 2026-02-01

Errors are RFC 9457 problem documents. The `code` is the stable contract — branch on it. The `detail` string is written for a human reading a log and may change without notice.

{
  "type": "https://framique.com/docs/v1/errors#insufficient_scope",
  "title": "Insufficient scope",
  "status": 403,
  "code": "insufficient_scope",
  "detail": "This credential does not carry orders.write.",
  "request_id": "req_01JB4…"
}
StatusCodeMeaningRetry?
400invalid_requestMalformed JSON or an unknown fieldNo — fix the call
401unauthenticatedMissing, expired or revoked credentialNo — re-auth
403insufficient_scopeAuthenticated, but the scope is not grantedNo
404not_foundNo such row in *this* tenantNo
409idempotency_conflictKey reused with a different bodyNo
422validation_failedShape is fine, values are notNo
429rate_limitedBucket exhaustedYes, after `retry-after`
503upstream_unavailableA dependency is down or timed outYes, with backoff

Retry policy we expect#

const RETRYABLE = new Set([429, 502, 503, 504]);

export async function call(url: string, init: RequestInit, attempt = 0): Promise<Response> {
  const res = await fetch(url, init);
  if (!RETRYABLE.has(res.status) || attempt >= 5) return res;

  const after = Number(res.headers.get("retry-after"));
  // Full jitter: a fleet retrying in lockstep is a self-inflicted outage.
  const backoff = Number.isFinite(after) && after > 0
    ? after * 1000
    : Math.random() * Math.min(30_000, 2 ** attempt * 500);

  await new Promise((r) => setTimeout(r, backoff));
  return call(url, init, attempt + 1);
}