Errors, timeouts and retries

What a failed request looks like, what the client retries on its own, and how to handle failures in loaders and components.

Read this when a request fails, or before you decide what a loader should do when the API is down.

What failure looks like

The client rejects the promise; it never returns a half-result. The error is one of:

ErrorWhenWhat you can read
HTTPErrorThe server answered with a non-2xx statuserror.response.status, error.data (the parsed body)
TimeoutErrorNo answer within the timeouterror.request
NetworkErrorOffline, DNS or connection failureerror.request, error.cause
AbortErrorYou cancelled with an AbortSignala standard DOMException

These come from ky, the library under the client. A 404 or 422 is an HTTPError, not a normal return:

import { api } from '@salla.sa/twilight-theme-engine/api/client';

try {
  await api.get('products/0/details').json();
} catch (error) {
  const status = (error as { response?: { status?: number } }).response?.status;
  if (status === 404) { /* no such product */ }
  else throw error;
}

Timeout and retries

  • Timeout: 8 seconds per attempt. Change it for one call with api.get(path, { timeout: 3000 }). store.settings uses 5 seconds and the apps calls 2.5 seconds.
  • Retries: GET, PUT, HEAD, DELETE and OPTIONS are retried up to 2 times on a network error or on status 408, 413, 429, 500, 502, 503 or 504, waiting 0.3 s then 0.6 s (or the server's Retry-After). POST and PATCH are never retried, so a failed checkout step is not submitted twice.
  • A timeout is not retried. Other 4xx errors such as 401 or 404 are not retried either.

To change this for one call, set retry: api.get(path, { retry: 0 }). The full numbers are in the client reference.

In a route loader

An error thrown in a loader goes to the route's error boundary. Two helpers from @salla.sa/twilight-theme-engine/providers shape that:

  • orThrow(promise) turns a 404 or 410 into the not-found page and rethrows every other error (it logs the error with console.debug). Modules expose it as product.findOrThrow(id), cart.getOrThrow(id) and similar.
  • orUnauthorized(promise) turns a 401 or 403 into the unauthorized page and rethrows everything else untouched.

The two combine: order.findOrThrow(id) is orThrow(orUnauthorized(order.find(id))), so a 401 or 403 becomes the unauthorized page and a 404 or 410 the not-found page.

import { orUnauthorized } from '@salla.sa/twilight-theme-engine/providers';
import { product } from '@salla.sa/twilight-theme-engine/api/product';
import { order } from '@salla.sa/twilight-theme-engine/api/order';

const found = await product.findOrThrow(id);        // 404 or 410: not-found page
const mine = await orUnauthorized(order.find(id));  // 401 or 403: unauthorized page

Only 404 and 410 become the not-found page. A 500, a timeout or a 401 still reaches the route's error boundary, so use orThrow for pages that mean nothing without the data, and catch the error yourself where you want a softer failure.

In a component

With a query, failure is state, not an exception. The engine's query client retries a failed query once, so isError only becomes true after that retry:

import { useQuery } from '@tanstack/react-query';
import { order } from '@salla.sa/twilight-theme-engine/api/order';

export function Orders() {
  const { data, error, isPending, refetch } = useQuery(order.queries.list());

  if (isPending) return <p>Loading</p>;
  if (error) return <button onClick={() => refetch()}>Try again</button>;
  return <p>{data.data.length} orders</p>;
}

For a one-off action, such as a button that calls an endpoint, wrap it with useAsyncFn. It gives you loading and error and does not throw.

Cancelling a request

Pass an AbortSignal. A query function receives one, and modules that accept signal forward it:

queryFn: ({ signal }) => api.get('products', { searchParams, signal }).json(),

TanStack Query aborts the request when the component unmounts or the key changes. The abort rejects the call, so do not show it to the customer as a failure.

Next

On this page