GuidesHandle errors

Handle errors robustly

Every non-2xx response from the API carries a machine-readable error envelope. The one discipline that keeps a storefront honest: a failed read is never an empty result. Unknown and absent are different types — a 404 is a real “no such thing”, but a 503 means “we could not find out”, and turning either into [] lies to the caller.

The error body is nested

Every error response is JSON with a single error object. Branch on code, not on message:

{
  "error": {
    "code": "invalid_body",
    "message": "url and a non-empty events array are required",
    "correlationId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed"
  }
}
  • code — a stable, machine-matchable string.
  • message — human-readable; treat its exact wording as unstable.
  • correlationId — optional; include it in support reports when present.

Status codes at a glance

StatusMeaningRetry?
400Malformed request — bad JSON or a missing field (invalid_body), or a missing Idempotency-Key header on a mutating call (missing_idempotency_key).No — fix the request.
401No valid credential — missing, unknown, or revoked.No.
402The charge was declined (payment_declined).No — do not auto-retry a charge.
403Authenticated but not allowed — lacks a scope, or an issuance would escalate (scope_escalation).No.
404No such resource for this tenant (not_found). Real absence.No.
409Conflict with current state — an illegal transition (invalid_transition), or an action refused on a fulfilled order (order_fulfilled_use_returns).No.
412Precondition failed — the resource changed or was not in the required state (precondition_failed).Re-read, then retry — but only when the precondition is stale state. A deterministic verdict (e.g. coupon_inactive / coupon_exhausted) repeats on retry; surface it instead.
429Rate limited (rate_limited). Respect retry-after.Yes — after backoff.
503A dependency read was unavailable (lookup_unavailable) — an honest “could not find out”, never a laundered empty result.Yes — with backoff.

See Errors for the full code reference.

Catch FlightdeckError in the SDK

Every non-2xx throws FlightdeckError, which carries the HTTP status and the parsed body. Branch on the status; reach into body.error.code for the finer distinction:

import { FlightdeckError } from "@dscodotco/sdk";
 
try {
  return await store.products.get(slug);
} catch (err) {
  if (err instanceof FlightdeckError && err.status === 404) {
    return notFound();          // a real "no such product"
  }
  throw err;                    // transport / 5xx — do NOT swallow into []
}
🚫

Never write catch { return [] }. It tells the caller “you have none of these” when the truth is “we could not find out.” Distinguish 404 (real absence) from every other failure. A 503 is explicitly “the read was unavailable”, never an empty catalog or a zero balance.

Do not auto-retry a decline

A 402 is a real decline, not a transient failure. Show the shopper a retry prompt — never silently re-submit the charge:

try {
  const result = await store.checkout.submit(body, { idempotencyKey });
  return confirm(result.order.id);
} catch (err) {
  if (err instanceof FlightdeckError && err.status === 402) {
    // e.g. err.body.error.code === "payment_declined"
    return declined(err);       // "try another card" — do NOT auto-retry
  }
  throw err;
}

Retry 5xx with a deterministic idempotency key

A 503 or other 5xx on a money-mutating call is safe to retry only because the idempotency key is deterministic — derived from the business event, never a fresh random value. A retried submit with the same key replays the original outcome instead of charging twice:

async function submitWithRetry(body, key: string, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    try {
      return await store.checkout.submit(body, { idempotencyKey: key });
    } catch (err) {
      const retryable = err instanceof FlightdeckError && err.status >= 500;
      if (!retryable || i === attempts - 1) throw err;
      await sleep(2 ** i * 250);   // exponential backoff
    }
  }
}

The same key across every attempt is what makes this safe — see Build a custom frontend for how the key is derived per checkout attempt.

Back off on 429

The credential-verification limiter (and some management routes) return 429 with a retry-after header in seconds and the code rate_limited. Read the header, wait at least that long, then back off exponentially on repeated 429s:

try {
  return await callSomeLimitedRoute();
} catch (err) {
  if (err instanceof FlightdeckError && err.status === 429) {
    const retryAfter = Number(err.body?.error?.retryAfter ?? 1); // or the retry-after header
    await sleep(retryAfter * 1000);
    return callSomeLimitedRoute();
  }
  throw err;
}

Never hard-code a specific limit or window — the numbers are operational and subject to change. Treat 429 defensively everywhere, and never assume a route’s current throttling is permanent. Public catalog reads are unthrottled today.