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. Carriescode,status,detail, RFC 9457 fields (type,title,instance), and typedextensions.code— Rift-specific stable string identifier (rate_limited,validation_error,insufficient_inventory, …). The discriminator. Independent of HTTPstatus.extensions— code-specific structured data the server attaches (retryAfterSecondsforrate_limited,unavailableTicketTypeIdsforinsufficient_inventory, etc.). Typed per code via theRiftApiErrorExtensionsregistry.- 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'scodeandextensions.*live here). - Narrowing — TypeScript flow-typing that restricts
err's type to a specific code soerr.extensionsbecomes the registered shape. Done viaisRiftApiError(err, "<code>").
The contract
Every consumer error handler MUST
- Catch errors with
isRiftApiError(err)before readingerr.code. Other thrown values (network failures, AbortError, transport bugs) reach the catch block too — they need separate handling. - Narrow on
err.codewith the two-argisRiftApiError(err, "code")form when readingerr.extensions. Casting bypasses the typed registry and breaks on the next field rename. - Display recoverable errors to the user with action-oriented
language.
rate_limitedis "retry in N seconds", not "request failed".insufficient_inventoryis "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.messagefor user-facing strings. The message is built fromcode: detailfor debugging convenience; the user-facing copy belongs in your localization layer keyed byerr.code. - Branch on
err.statusinstead oferr.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 typedRecord<string, unknown>— the typed shape only appears after theisRiftApiError(err, "<code>")check. - Swallow
RiftApiErrorsilently. Even errors you don't act on should fireon.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 theRetry-Afterheader forrate_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 —
useOrderStatusstops 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
unknownfrom acode: "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 rawcodevalue inerr.extensionsfor debugging.AbortErrorduring a long request — the SDK lets these propagate unwrapped (cancellation is the consumer's choice). Checkerr.name === "AbortError"beforeisRiftApiError.- Captcha errors during reservation/checkout —
altcha_payload_missingandaltcha_payload_invalidshouldn't reach your handler.<CaptchaWidget>refreshes the solution and the next request succeeds. If they DO reach you, the widget is unmounted orrequireCaptchais missing on the button. order_status_token_expiredmid-polling —useOrderStatusstops 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_validon 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_unclaimableonGET /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:
- Verify the SDK's
RiftApiErrorCodeunion includes it. If not, update@feelrift/reactto the latest version — the SDK is the typed source of truth. - Confirm the code's typed extensions are in
RiftApiErrorExtensions. The shape is what yourisRiftApiError(err, "newCode")branch will see inerr.extensions. - Add the branch in specificity order — specific codes before generic fallbacks.
- 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. Checkerr.codedirectly 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.fooisundefinedafter 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 asfield?: T. - "Rate-limit errors fire but
retryAfterSecondsis missing" — the server returned the error without the extension AND without aRetry-Afterheader. 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. PassonReserveErrorto capture the error and return early; or replace the component with the underlying hook (useReservation) for full UI control.
Related
- Errors reference — every
registered
codewith its typedextensionsshape, HTTP status, and what triggers it. <RiftProvider>reference —onprop — theerrorcallback that captures every error the SDK sees.- RFC 9457 Problem Details for HTTP APIs
— the wire format
RiftApiErrormirrors.