Error Handling
Handle API errors, retries, and rate limits
Result errors and availability exceptions
Public SDK calls normally return SdkResult<T>. Handle error before using
data; do not replace a failed response with an empty array or a 404.
const {data, error} = await storefront.catalog.getProduct("my-product");
if (error?.status === 404) return renderNotFound();
if (error) return renderRetryableError(errorMessage(error, "en"));
return renderProduct(data);Server catalog clients may enable throwOnAvailabilityError: true. In that
mode, exhausted 429/5xx/network/timeout failures throw BehioApiError or
BehioNetworkError; business errors such as 400/401/404 still return in the
result. A route that renders its own failure UI must handle both paths:
import {
BehioApiError, BehioNetworkError, toSdkError, type SdkResult,
} from "@behio/storefront-sdk";
async function readCatalogResult<T>(request: Promise<SdkResult<T>>): Promise<SdkResult<T>> {
try {
return await request;
} catch (error) {
if (error instanceof BehioApiError || error instanceof BehioNetworkError) {
return {data: null, error: toSdkError(error)};
}
throw error;
}
}Use this adapter only where the caller renders an explicit error state. Keep the default throwing behavior for other server catalog consumers. Render localized failure content and retry in the first HTML; verify the actual HTML with JavaScript disabled. A client error boundary alone does not prove that the initial response contains the failure message.
If price entitlement lookup fails, the public catalog returns HTTP 503 with
be.storefront.pricingUnavailable. It must never substitute unrestricted
guest prices. Do not publish a Product/Offer or an indexable error page.
An independent review-list failure can keep the product visible while showing
an explicit review error and retry; it must not claim that no reviews exist.
Localized Error Messages (SDK 1.10+)
Every public API error now carries a stable machine code in its body
(code, e.g. be.storefront.insufficientStock) next to the legacy English
message. The SDK ships Czech, Slovak and English translations for all of
them, so you can show the customer a proper sentence without maintaining
your own error map:
import { errorMessage } from "@behio/storefront-sdk";
const { data, error } = await storefront.cart.addItem({ productId, quantity });
if (error) {
showToast(errorMessage(error, "cs"));
// "Produkt Tricko neni v pozadovanem mnozstvi skladem."
}errorMessage(error, locale) accepts anything an SDK call produces
(SdkError, BehioApiError, or a caught unknown) and resolves in this
order: translation for the body code (with {params} interpolation), then
the English message from the response, then a generic sentence in the
requested locale. Supported locales: "cs" | "sk" | "en".
Related helpers, all exported from the package root:
| Helper | Returns |
|---|---|
errorCode(error) | the stable be.* code, or null |
errorParams(error) | interpolation params ({name: "Tricko"}) |
validationFieldMessages(error, locale) | per-field translated map for be.validation.failed bodies ({"email": "Neplatna e-mailova adresa."}) |
The English message field stays in responses for backward compatibility
and will only be removed in a future major version. New code should branch
on code (or errorCode()), never on the message text.
Error Properties
BehioApiError
| Property | Type | Description |
|---|---|---|
status | number | HTTP status code (400, 401, 404, 429, 500, ...) |
code | BehioErrorCode | Semantic error code resolved from status and body |
message | string | Human-readable error message |
body | unknown | Raw response body from the API |
isRetryable | boolean | true for status >= 500 or 429 |
BehioNetworkError
| Property | Type | Description |
|---|---|---|
code | "NETWORK_ERROR" | "TIMEOUT" | Whether it was a timeout or general network failure |
message | string | Error description |
isRetryable | boolean | Always true |
All Error Codes
type BehioErrorCode =
| "UNAUTHORIZED" // 401
| "FORBIDDEN" // 403
| "NOT_FOUND" // 404
| "VALIDATION_ERROR" // 400
| "CONFLICT" // 409
| "RATE_LIMITED" // 429
| "CART_EMPTY" // empty cart at checkout
| "PRODUCT_NOT_FOUND" // product does not exist
| "INVALID_CREDENTIALS" // wrong email/password
| "INVALID_DISCOUNT" // discount code invalid
| "DISCOUNT_EXPIRED" // discount code expired
| "TOKEN_EXPIRED" // auth token expired
| "TOKEN_INVALID" // auth token invalid
| "EMAIL_ALREADY_EXISTS" // 409 on registration
| "ORDER_NOT_CANCELLABLE" // order cannot be cancelled
| "INTERNAL_ERROR" // 5xx
| "NETWORK_ERROR" // network failure
| "TIMEOUT" // request timed out
| "UNKNOWN"; // unrecognized errorCommon Error Scenarios
400: Validation Error
The API rejected invalid input (missing fields, wrong format).
const {error} = await storefront.checkout.createOrder(input);
if (error?.status === 400) {
showFieldErrors(validationFieldMessages(error, "en"));
}401: Unauthorized
The access token is missing or expired. The SDK handles this automatically (see Token Refresh below), but if refresh also fails, the request returns SdkResult.error.
404: Not Found
The requested resource does not exist.
const {error} = await storefront.catalog.getProduct("nonexistent-slug");
if (error?.status === 404) {
// Render the missing-resource page. Other errors require their own state.
}A 404 with the code be.storefront.siteKeyNotAllowed is different: the key
belongs to a website that does not sell yet. Shop info, SEO, scripts, pages,
menus, blogs, forms, collections and analytics work, while products, cart, checkout and
customer accounts stay closed until the merchant adds a shop to the website
(Behio admin, or the MCP tool site-commerce-enable). Hide the shop parts of
the template instead of showing an error page:
import {errorCode} from "@behio/storefront-sdk";
const {error} = await storefront.catalog.getProducts({limit: 12});
if (errorCode(error) === "be.storefront.siteKeyNotAllowed") {
// Website without a shop: render the site without the catalog.
}429: Rate Limited
Too many requests. The SDK will automatically retry (see below).
500: Server Error
Internal server error. The SDK will automatically retry on 5xx errors.
Automatic Retry
The SDK retries failed requests on 5xx errors, 429 (rate limited), and network failures. Configure retry behavior in the constructor:
import { BehioStorefront } from "@behio/storefront-sdk";
const storefront = new BehioStorefront({
apiKey: "pk_live_xxx",
retries: 2, // Number of retries (default: 1)
retryDelay: 1000, // Base delay in ms (default: 1000)
timeout: 30000, // Request timeout in ms (default: 30000)
});How Retry Works
- The SDK attempts the request.
- If it fails with a retryable error, it waits
retryDelay * attemptms (linear backoff). - It retries up to
retriestimes. - If all attempts fail, return
SdkResult.error, or throw an availability exception when configured.
For example, with retries: 2 and retryDelay: 1000:
- Attempt 1 fails → wait 1000ms
- Attempt 2 fails → wait 2000ms
- Attempt 3 fails → return the error (or throw with the availability flag)
Set retries: 0 to disable automatic retries entirely.
Availability Errors That Throw (throwOnAvailabilityError)
By default every failure ends up in SdkResult.error and it is easy to write
result.data ?? [], which turns a rate limit or an outage into a page that
looks like a shop with no products. For server-rendered catalog clients, set:
const storefront = new BehioStorefront({
apiKey: "pk_live_xxx",
throwOnAvailabilityError: true,
});With the flag on, availability failures (429, 5xx, network errors and
timeouts, once retries are exhausted) are thrown as BehioApiError /
BehioNetworkError instead of being returned in the tuple. Business
outcomes (400 validation, 401, 404 not found, 409, ...) still come back as
SdkResult.error, so notFound() handling and form validation keep working
unchanged. During ISR revalidation a thrown error keeps the last good page;
on a fresh render the visitor gets your error boundary instead of a lying
empty catalog. Available since 1.8.0.
Token Refresh
When a request returns 401 Unauthorized and the SDK has a refresh token stored, it automatically:
- Pauses the failed request
- Calls
auth.refresh()to get a new access token - Retries the original request with the new token
- Emits
auth:token-refreshon success
If the refresh itself fails:
- The SDK clears all tokens
- Emits
auth:token-refresh-failed - Returns a result error with status 401 to the original public request
// Listen for refresh failures to redirect to login
storefront.on("auth:token-refresh-failed", () => {
// Tokens have been cleared automatically
router.push("/login");
});
storefront.on("auth:token-refresh", () => {
// Optional: persist the new tokens
const accessToken = storefront.getAccessToken();
const refreshToken = storefront.getRefreshToken();
localStorage.setItem("tokens", JSON.stringify({ accessToken, refreshToken }));
});Concurrent Requests
If multiple requests receive 401 simultaneously, only one refresh is performed. All other requests wait for the same refresh to complete, then retry with the new token.
Rate Limiting
Monitoring Rate Limits
Check current rate limit status from the latest response headers:
const info = storefront.getRateLimitInfo();
console.log(info.remaining); // Requests remaining (null if no response yet)
console.log(info.reset); // Unix timestamp when the limit resetsRate Limit Warning Event
The SDK emits a rate-limit-warning event when remaining requests drop to 5 or fewer:
storefront.on("rate-limit-warning", (data) => {
const { remaining, reset } = data as { remaining: number; reset: number };
console.warn(`Rate limit low: ${remaining} requests remaining`);
console.warn(`Resets at: ${new Date(reset * 1000).toISOString()}`);
});React Hook Errors
All React hooks are built on TanStack Query. Errors are available through the error property returned by each hook:
import { useProduct } from "@behio/storefront-sdk/react";
import { BehioApiError } from "@behio/storefront-sdk";
function ProductPage({ slug }: { slug: string }) {
const { data, error, isLoading } = useProduct(slug);
if (isLoading) return <div>Loading...</div>;
if (error) {
if (error instanceof BehioApiError && error.is("NOT_FOUND")) {
return <div>Product not found</div>;
}
return <div>Something went wrong: {error.message}</div>;
}
return <h1>{data.name}</h1>;
}Mutation Errors
For mutations (cart, checkout, auth), errors appear in the error property or can be caught in onError callbacks:
import { useCart } from "@behio/storefront-sdk/react";
import { BehioApiError } from "@behio/storefront-sdk";
function AddToCartButton({ productId }: { productId: string }) {
const { addItem } = useCart();
const handleAdd = async () => {
try {
await addItem.mutateAsync({ productId, quantity: 1 });
} catch (error) {
if (error instanceof BehioApiError) {
if (error.is("PRODUCT_NOT_FOUND")) {
showToast("This product is no longer available");
} else if (error.is("VALIDATION_ERROR")) {
showToast("Invalid quantity");
}
}
}
};
return <button onClick={handleAdd}>Add to Cart</button>;
}Best Practices
Global Error Listener
Use the error event for centralized logging:
storefront.on("error", (error) => {
// Send to error tracking (Sentry, etc.)
console.error("[Behio SDK]", error);
});React Error Boundary
Wrap your app in an error boundary to catch unhandled errors:
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";
function App() {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<p>Something went wrong: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}
>
<ShopContent />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}Toast Notifications
Map error codes to user-friendly messages:
import { BehioApiError, type BehioErrorCode } from "@behio/storefront-sdk";
const errorMessages: Partial<Record<BehioErrorCode, string>> = {
UNAUTHORIZED: "Please log in to continue",
NOT_FOUND: "The requested item was not found",
VALIDATION_ERROR: "Please check your input",
RATE_LIMITED: "Too many requests, please wait",
CART_EMPTY: "Your cart is empty",
INVALID_CREDENTIALS: "Wrong email or password",
INVALID_DISCOUNT: "This discount code is invalid",
DISCOUNT_EXPIRED: "This discount code has expired",
EMAIL_ALREADY_EXISTS: "An account with this email already exists",
INTERNAL_ERROR: "Server error, please try again later",
};
function getErrorMessage(error: unknown): string {
if (error instanceof BehioApiError) {
return errorMessages[error.code] ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return "An unexpected error occurred";
}