SDKError handling

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

PropertyTypeMeaning
namestringAlways "FlightdeckError".
messagestringflightdeck request failed with status <status>.
statusnumberThe HTTP status code.
bodyunknownThe 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:

StatusMeaningSuggested handling
400Bad request (e.g. missing Idempotency-Key, missing required query)Fix the call; do not retry blindly.
401 / 403Bad or unauthorized credentialFail loud; a retry will not help.
404Real absence (unknown slug, tenant not live, order not owned)Render “not found” — a genuine empty state.
409 / 422Conflict / unprocessable (e.g. coupon_exhausted, validation)Surface body.error.code to the caller; do not retry.
429Rate limitedBack off and retry.
5xxServer / transport failureRetry 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