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
| Status | Meaning | Retry? |
|---|---|---|
400 | Malformed 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. |
401 | No valid credential — missing, unknown, or revoked. | No. |
402 | The charge was declined (payment_declined). | No — do not auto-retry a charge. |
403 | Authenticated but not allowed — lacks a scope, or an issuance would escalate (scope_escalation). | No. |
404 | No such resource for this tenant (not_found). Real absence. | No. |
409 | Conflict with current state — an illegal transition (invalid_transition), or an action refused on a fulfilled order (order_fulfilled_use_returns). | No. |
412 | Precondition 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. |
429 | Rate limited (rate_limited). Respect retry-after. | Yes — after backoff. |
503 | A 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.
Related
- Errors — the full status-code and error-code reference.
- Pagination and rate limits — what is and is not
throttled, and the
limit/offsetshape. - Build a custom frontend — the BFF and idempotency-key pattern.