Error handling — FlightdeckError
Every non-2xx response from either client throws FlightdeckError, carrying the
parsed error body. It is exported from the package so you can instanceof-narrow
it. There is no “soft error” mode: a failed call throws — it never resolves to an
empty or zero value.
import { FlightdeckError } from "@dscodotco/sdk";Shape
| Property | Type | Meaning |
|---|---|---|
name | string | Always "FlightdeckError". |
message | string | flightdeck request failed with status <status>. |
status | number | The HTTP status code. |
body | unknown | The parsed error body (see below). undefined on a 204 or a non-JSON error response. |
FlightdeckError extends Error, so a plain catch still gets a real Error;
instanceof FlightdeckError narrows to the fields above.
The error body envelope
The API’s error envelope is nested:
type ErrorBody = {
error: {
code: string; // e.g. "invalid_tenant", "invalid_body", "payment_declined"
message: string; // human-readable
correlationId?: string; // uuid, for support / log correlation
};
};err.body is typed unknown on purpose — narrow it before reading fields. The
code is the stable, machine-readable discriminator; branch on it, not on the
message string.
function errorCode(err: unknown): string | undefined {
if (
err instanceof FlightdeckError &&
typeof err.body === "object" && err.body !== null &&
"error" in err.body
) {
const inner = (err.body as { error?: { code?: unknown } }).error;
return typeof inner?.code === "string" ? inner.code : undefined;
}
return undefined;
}Catching and narrowing
import { FlightdeckError } from "@dscodotco/sdk";
try {
const { product } = await store.products.get(slug);
return product;
} catch (err) {
if (err instanceof FlightdeckError && err.status === 404) {
return notFound(); // a real "no such product"
}
throw err; // transport / 5xx — do NOT swallow into []
}Do not turn a thrown error into an empty result. catch { return [] }
tells the shopper “you have none of these” when the truth is “we couldn’t find
out.” Distinguish a real absence (404) from a failure (rethrow, or render an
error state). This is a platform-wide convention: a failed read is never an
empty result.
Mapping status to behavior
A reasonable default mapping for a storefront BFF:
| Status | Meaning | Suggested handling |
|---|---|---|
400 | Bad request (e.g. missing Idempotency-Key, missing required query) | Fix the call; do not retry blindly. |
401 / 403 | Bad or unauthorized credential | Fail loud; a retry will not help. |
404 | Real absence (unknown slug, tenant not live, order not owned) | Render “not found” — a genuine empty state. |
409 / 422 | Conflict / unprocessable (e.g. coupon_exhausted, validation) | Surface body.error.code to the caller; do not retry. |
429 | Rate limited | Back off and retry. |
5xx | Server / transport failure | Retry with the same idempotency key (see below), then surface an error state — never an empty result. |
try {
return await store.checkout.submit(body, { idempotencyKey });
} catch (err) {
if (err instanceof FlightdeckError) {
switch (errorCode(err)) {
case "payment_declined": return { ok: false, reason: "declined" };
case "coupon_exhausted": return { ok: false, reason: "coupon" };
}
if (err.status >= 500) throw err; // let the retry layer handle it
}
throw err;
}Retry and idempotency on 5xx
The only calls safe to blind-retry are idempotent reads (GETs). Money-mutating
calls (checkout.submit, checkout.orders.*) are safe to retry only when you
reuse the same deterministic idempotencyKey — the API deduplicates the replay
and returns the original outcome (for checkout.submit, an outcome: "already_placed" with replayed: true) instead of charging twice.
Derive the key from the business event
const idempotencyKey = `cart-${cartId}:attempt-${attempt}`;Never randomUUID() per call — a random key makes a retry a second order.
Retry the same key on 5xx / network failure
async function submitWithRetry(body, idempotencyKey, tries = 3) {
for (let i = 0; i < tries; i++) {
try {
return await store.checkout.submit(body, { idempotencyKey });
} catch (err) {
const retryable = err instanceof FlightdeckError && err.status >= 500;
if (!retryable || i === tries - 1) throw err;
await new Promise((r) => setTimeout(r, 250 * 2 ** i)); // backoff
}
}
}Treat a replay as success
A retry that lands after the original succeeded returns outcome: "already_placed" / replayed: true. Handle it as a placed order, not a
duplicate.
A 400 for a missing Idempotency-Key is a bug in your call, not a
transient failure — every checkout mutation requires it. Do not retry your way
around a 400.
See also
- Storefront client — the checkout methods and their idempotency argument.
- Merchant / low-level client —
request()throws the sameFlightdeckError. - TypeScript usage — typing
err.bodyagainst the generatedErrorBody.