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:
| Error | When | What you can read |
|---|---|---|
HTTPError | The server answered with a non-2xx status | error.response.status, error.data (the parsed body) |
TimeoutError | No answer within the timeout | error.request |
NetworkError | Offline, DNS or connection failure | error.request, error.cause |
AbortError | You cancelled with an AbortSignal | a 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.settingsuses 5 seconds and theappscalls 2.5 seconds. - Retries:
GET,PUT,HEAD,DELETEandOPTIONSare 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'sRetry-After).POSTandPATCHare 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 withconsole.debug). Modules expose it asproduct.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 pageOnly 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
- Write your own endpoint: a module whose errors behave like the built-in ones.
- Loading data: loaders and error boundaries.
- Modules: each page says which methods swallow errors and which throw.
Authentication and scope
How each request learns who the customer is and which store, branch and theme version it is for, and what that means for your query keys.
Write your own endpoint
Build a theme-side API module on top of the shared client, with a typed function and a query whose cache key is correct.