Saltar al contenido principal

Error handling

How @feelrift/react surfaces server errors, how to narrow them to typed shapes per error code, and the canonical patterns for the most common failure modes.

What this is and why it exists

Every server-side failure the SDK encounters arrives in your code as a RiftApiError — an instanceof-able JavaScript class that mirrors the server's RFC 9457 Problem Details wire shape. The discriminator is the code field; per-code typed data lives in extensions. Two-arg type guards narrow both at once.

A consistent error type is the foundation that lets every UI in every consumer app react to the same failure modes the same way — sold-out tickets show the same banner, rate limits show the same retry timer, invalid card details show the same field-level messages. This guide is the contract for what handlers must do; the errors reference is the per-code lookup table.

Vocabulary

  • RiftApiError — the SDK's typed error class. Every server failure surfaces as an instance of this class. Carries code, status, detail, RFC 9457 fields (type, title, instance), and typed extensions.
  • code — Rift-specific stable string identifier (rate_limited, validation_error, insufficient_inventory, …). The discriminator. Independent of HTTP status.
  • extensions — code-specific structured data the server attaches (retryAfterSeconds for rate_limited, unavailableTicketTypeIds for insufficient_inventory, etc.). Typed per code via the RiftApiErrorExtensions registry.
  • Problem Details (RFC 9457) — the wire format the server uses for every error response. Five canonical fields (type, status, title, detail, instance) plus arbitrary extension members (Rift's code and extensions.* live here).
  • Narrowing — TypeScript flow-typing that restricts err's type to a specific code so err.extensions becomes the registered shape. Done via isRiftApiError(err, "<code>").

The contract

Every consumer error handler MUST

  • Catch errors with isRiftApiError(err) before reading err.code. Other thrown values (network failures, AbortError, transport bugs) reach the catch block too — they need separate handling.
  • Narrow on err.code with the two-arg isRiftApiError(err, "code") form when reading err.extensions. Casting bypasses the typed registry and breaks on the next field rename.
  • Display recoverable errors to the user with action-oriented language. rate_limited is "retry in N seconds", not "request failed". insufficient_inventory is "tickets sold out", not "409 Conflict".
  • Treat code: "unknown" as a generic-error fallback. Surface a generic retry message, never a stack trace.

Every consumer error handler MUST NOT

  • Read err.message for user-facing strings. The message is built from code: detail for debugging convenience; the user-facing copy belongs in your localization layer keyed by err.code.
  • Branch on err.status instead of err.code. The status is for HTTP-aware infrastructure (CDN, fetch retries); the code is for application logic.
  • Read err.extensions.<field> without narrowing first. The class field is typed Record<string, unknown> — the typed shape only appears after the isRiftApiError(err, "<code>") check.
  • Swallow RiftApiError silently. Even errors you don't act on should fire on.error (configured on <RiftProvider>) so they reach Sentry / your observability layer.

The SDK handles for you

  • Parsing the RFC 9457 wire shape into a typed RiftApiError, including extracting extensions and normalizing the Retry-After header for rate_limited.
  • Wrapping transport failures (network errors, non-JSON bodies) in code: "unknown" so handlers always receive one shape.
  • Pre-flight checks that throw client-side before the request leaves: code: "unauthenticated" (auth required, no session), code: "captcha_unavailable" (captcha required, widget not mounted).
  • Captcha refresh after altcha_payload_* errors. The <CaptchaWidget> automatically re-solves; consumers don't need to handle these codes manually.
  • Order-status token errors during polling — useOrderStatus stops the poll loop and surfaces the error on the next render.

The standard pattern

import { isRiftApiError } from "@feelrift/react";

async function reserve() {
try {
await createReservation({ items, estimate });
} catch (err) {
if (!isRiftApiError(err)) throw err;

if (isRiftApiError(err, "rate_limited")) {
showBanner(
t("error.rateLimited", {
seconds: err.extensions.retryAfterSeconds,
}),
);
return;
}

if (isRiftApiError(err, "insufficient_inventory")) {
refetchAvailability();
showBanner(t("error.soldOut"));
return;
}

if (isRiftApiError(err, "max_per_order_exceeded")) {
showBanner(
t("error.maxPerOrder", {
count: err.extensions.violations.length,
}),
);
return;
}

showBanner(t("error.generic"));
}
}

The pattern: outer isRiftApiError(err) to confirm we own this error, then per-code isRiftApiError(err, "code") branches in specificity order. Specific codes win; generic fallback last. Locale strings keyed by code, never by err.message or err.detail.

Anti-patterns

Casting err.extensions instead of narrowing

// Wrong
catch (err) {
if (isRiftApiError(err) && err.code === "rate_limited") {
const ext = err.extensions as { retryAfterSeconds: number };
retry(ext.retryAfterSeconds);
}
}

Why this is wrong: the cast bypasses the typed registry. When the server renames retryAfterSeconds or moves the field to a different code, TypeScript can't catch the breakage. The two-arg isRiftApiError(err, "rate_limited") form gives the exact same runtime check with full type inference.

// Right
catch (err) {
if (isRiftApiError(err, "rate_limited")) {
retry(err.extensions.retryAfterSeconds); // typed: number
}
}

Branching on err.status instead of err.code

// Wrong
catch (err) {
if (isRiftApiError(err) && err.status === 409) {
showBanner("Conflict");
}
}

Why this is wrong: several codes share a 409 with different remediation — insufficient_inventory means refetch availability and show "sold out", while order_in_terminal_state means a previous attempt already completed and the user should be routed forward. Branching on status conflates them.

// Right
catch (err) {
if (isRiftApiError(err, "insufficient_inventory")) refetchAvailability();
else if (isRiftApiError(err, "order_in_terminal_state"))
routeForward(err.extensions.orderStatus);
}

Showing err.detail to the user

// Wrong
catch (err) {
if (isRiftApiError(err)) {
showBanner(err.detail);
}
}

Why this is wrong: detail is English-only, server-controlled, and tuned for engineers debugging. Users see strings like "The reservation token has expired. Please start a new reservation." in the wrong language, with no localization control.

// Right — locale strings keyed by code
catch (err) {
if (isRiftApiError(err)) {
showBanner(t(`error.${err.code}`, { fallback: t("error.generic") }));
}
}

Swallowing RiftApiError silently

// Wrong
catch (err) {
if (isRiftApiError(err)) return; // observability blind spot
throw err;
}

Why this is wrong: every handler decides what to show the user, but observability needs to see every failure. Without it, you don't know rate-limit incidents are spiking until users complain.

// Right — let `<RiftProvider on={{ error }}>` see everything
catch (err) {
if (isRiftApiError(err)) {
onError(err); // pipes to Sentry via <RiftProvider on>
return;
}
throw err;
}

The on.error callback on <RiftProvider> is already wired into the SDK's own components — only direct hook consumers need to forward errors manually.

Edge cases

  • unknown from a code: "unknown" server response — the SDK saw a non-2xx response with a code it doesn't recognize (server added a new code the SDK hasn't been updated for). Treat as generic; the SDK preserves the raw code value in err.extensions for debugging.
  • AbortError during a long request — the SDK lets these propagate unwrapped (cancellation is the consumer's choice). Check err.name === "AbortError" before isRiftApiError.
  • Captcha errors during reservation/checkoutaltcha_payload_missing and altcha_payload_invalid shouldn't reach your handler. <CaptchaWidget> refreshes the solution and the next request succeeds. If they DO reach you, the widget is unmounted or requireCaptcha is missing on the button.
  • order_status_token_expired mid-pollinguseOrderStatus stops the poll automatically. The order itself may have completed out-of-band; consumers should route the user to a "check your email" UI rather than restart the flow.
  • reservation_no_longer_valid on retry — the reservation token is signed but the underlying order is already in a terminal state (complete). Means a previous attempt succeeded. Route the user forward, don't restart.
  • attendee_unclaimable on GET /orders — the attendee row matching the user's verified email is already linked to a different account. The user needs to sign in with the account that owns that email. Surface a "wrong account" message.

How to extend

Adding a new error-code handler

When the server ships a new code:

  1. Verify the SDK's RiftApiErrorCode union includes it. If not, update @feelrift/react to the latest version — the SDK is the typed source of truth.
  2. Confirm the code's typed extensions are in RiftApiErrorExtensions. The shape is what your isRiftApiError(err, "newCode") branch will see in err.extensions.
  3. Add the branch in specificity order — specific codes before generic fallbacks.
  4. Add the locale string under t("error.<newCode>").

Catching errors from the typed client (@feelrift/react/client)

The typed client methods throw the same RiftApiError as the React hooks. The pattern is identical — only the surface differs:

import { createRiftClient, isRiftApiError } from "@feelrift/react/client";

const client = createRiftClient({ baseUrl: "https://api.feelrift.com" });

try {
await client.checkout("evt_summerfest_2026", { reservationToken, email });
} catch (err) {
if (isRiftApiError(err, "reservation_token_expired")) {
// restart reservation flow
}
}

Failure modes & debugging

  • "isRiftApiError(err, "code") never matches" — the SDK doesn't know the code. Check err.code directly in the debugger; if it's a string the registry doesn't list, the SDK version is behind the server. Update the SDK.
  • "err.extensions.foo is undefined after narrowing" — the server didn't include the field. Either the server contract changed (compare with the OpenAPI spec) or the field is genuinely optional and absent on this occurrence. The registry types optional fields as field?: T.
  • "Rate-limit errors fire but retryAfterSeconds is missing" — the server returned the error without the extension AND without a Retry-After header. Fall back to a default (e.g. 30s) and log the case for follow-up — the SDK extracts from both sources.
  • "My error handler runs but the user sees the SDK's built-in banner" — components like <ReserveButton> render their own inline error UI. Pass onReserveError to capture the error and return early; or replace the component with the underlying hook (useReservation) for full UI control.