Product Catalog

Errors and rate limits

Every storefront error answers one envelope with a stable code, and the SDK turns each into a typed error. This page lists them all, with the rate limits and what the client and the hooks do about them.

The error envelope

400 INVALID_REQUEST
{
  "error": {
    "code": "INVALID_REQUEST",
    "message": "The request is not valid.",
    "requestId": "8f3c2a9e-1b4d-4f6a-9c2e-7d5b3a1f0e64",
    "issues": [{ "path": "limit", "message": "Must be a whole number from 1 to 100." }]
  }
}
  • error.code is stable; branch on it, never on the message. error.requestId identifies the request when you ask for help.
  • error.issues (at most 20) names the fields at fault, as { path, message }.
  • CART_REJECTED adds error.throttleCode, and CART_ITEMS_INVALID adds a top-level items list of refused lines.
  • Throttle's cart-session routes answer their own envelope, { error: { code, message, details? } }, with lowercase codes.

The SDK's errors

Everything the SDK throws is a CatalogError (which extends ThrottleError from @usethrottle/errors), with code, message, statusCode (0 when nothing answered), source ('catalog', 'throttle' for Throttle's cart session, or 'client' when the SDK made it), retryable, retryAfterSeconds, issues, details and requestId.

ClassWhen
RateLimitedErrorEvery 429. retryAfterSeconds is always set: from Retry-After, or 60 when that is missing or unusable.
OriginNotAllowedErrorORIGIN_NOT_ALLOWED, SERVER_KEY_IN_BROWSER, KEY_NOT_ALLOWED_IN_URL and Throttle's origin_not_allowed.
CustomerTokenInvalidErrorA refused customer token on a call that cannot fall back to regular prices.
CartLineErrorCART_ITEMS_INVALID and Throttle's quantity_unavailable and line_not_purchasable, with lines: { sku, code, maxQuantity? }[].
TimeoutErrorTIMEOUT: no answer within timeoutMs.
NetworkErrorNETWORK_ERROR: the request never got an answer (DNS, a CORS refusal, a dropped connection).
FeatureUnavailableErrorFEATURE_UNAVAILABLE: the catalog does not have this route yet (below).
CatalogErrorEverything else.
Telling errors apart
import {
  CartLineError,
  createCatalogClient,
  CustomerTokenInvalidError,
  FeatureUnavailableError,
  isCatalogError,
  NetworkError,
  OriginNotAllowedError,
  RateLimitedError,
  TimeoutError,
} from '@usethrottle/catalog';

const catalog = createCatalogClient({
  publishableKey: 'pc_pk_your_browser_key',
});

try {
  await catalog.products.list({ q: 'brake pads' });
} catch (error) {
  if (error instanceof RateLimitedError) {
    // Every 429. The client never retries one itself.
    console.warn(`Slow down: retry in ${error.retryAfterSeconds} s`);
  } else if (error instanceof TimeoutError || error instanceof NetworkError) {
    console.warn('No answer; safe to try again.');
  } else if (error instanceof OriginNotAllowedError) {
    console.error('This website is not on the key’s allowed websites.');
  } else if (error instanceof FeatureUnavailableError) {
    console.error('This catalog does not have that route yet.');
  } else if (error instanceof CustomerTokenInvalidError || error instanceof CartLineError) {
    console.error(error.code);
  } else if (isCatalogError(error)) {
    // Everything else: branch on the code. source says who answered.
    console.error(error.code, error.statusCode, error.source, error.requestId, error.issues);
  } else {
    // Your own AbortSignal's reason, or a bug: not a catalog error.
    throw error;
  }
}
  • FeatureUnavailableError. A 404 that carries the header X-Catalog-Route: unmatched means the catalog has no such route: an older catalog than the SDK. A 404 from a route that never answers a missing resource (config, the category tree, tags, the collections list, suggest, carts/check and carts) means the same. Any other 404 is NOT_FOUND.
  • Your own abort rethrows your AbortSignal's reason, never wrapped in a CatalogError.
  • isCatalogError also recognises an error from another copy of the SDK, such as the CommonJS build in an app that loads both; instanceof on a class holds within one build only.

Codes the SDK makes

CodeMeaning
TIMEOUTNo answer within timeoutMs. Retryable.
NETWORK_ERRORThe request failed before any answer. Retryable.
FEATURE_UNAVAILABLEThe catalog has no such route (see above).
INVALID_RESPONSEAn answer that is not the JSON expected, such as an HTML error page from a proxy. Its text is never put in the message.
INVALID_PUBLISHABLE_KEYThrown by createCatalogClient for a key that is not pc_pk_…. The key is never in the message.
CLIENT_ERRORA cartStorage adapter (or another of your functions) threw inside a hook action.

Timeouts. timeoutMs (10 seconds by default, per client or per call) covers one request. Reading the customer token gets its own full timeoutMs first, so a call that waits on a slow token refresh can take up to about twice as long.

Every storefront error code

Every code a /v1/storefront route answers, from the OpenAPI document, with the class the SDK throws for it. Retryable means the same request may succeed later.

error.codeHTTPRetryableSDK classMeaning
INVALID_REQUEST400NoCatalogErrorA parameter or body field is not valid; issues names each one. Also the SDK’s own refusal of a bad option or URL (statusCode 0, source client).
AUTHENTICATION_FAILED401NoCatalogErrorThe publishable key is missing, unknown, revoked or a secret key.
INFRASTRUCTURE_UNAVAILABLE503YesCatalogErrorA catalog service the request needs is down for a moment.
PROVIDER_UNAVAILABLE503YesCatalogErrorAn outside service the request needs did not answer.
INTERNAL_ERROR500NoCatalogErrorAn unexpected error. Quote the requestId.
CUSTOMER_TOKEN_INVALID401NoCustomerTokenInvalidErrorThe customer token is expired, revoked or for another store, or missing where one is required.
ORIGIN_NOT_ALLOWED403NoOriginNotAllowedErrorThe request’s Origin is not on the key’s allowed websites, or a browser key was used without an Origin.
SERVER_KEY_IN_BROWSER403NoOriginNotAllowedErrorA server key was used on a request that carries an Origin.
KEY_NOT_ALLOWED_IN_URL403NoOriginNotAllowedErrorA server key was sent in the URL (?key=). Server keys go only in X-Catalog-Key.
NOT_FOUND404NoCatalogErrorNo public product, category, collection or route with that handle (a draft, archived or unknown handle answers the same).
RATE_LIMITED429YesRateLimitedErrorToo many requests. Retry-After says when to try again.
STOREFRONT_UNAVAILABLE503YesCatalogErrorThe catalog could not be reached. Never cached.
REQUEST_BODY_TOO_LARGE413NoCatalogErrorThe request body is over its limit (64 KB for carts and reviews).
CART_CURRENCY_REQUIRED422NoCatalogErrorA cart with no currency, and no default currency in the store’s settings.
CART_CURRENCY_NOT_ENABLED422NoCatalogErrorThrottle has not enabled that currency for the store’s carts.
CHECKOUT_NOT_CONFIGURED409NoCatalogErrorThe catalog has no Throttle credentials for the store yet.
EXTENSION_UPGRADE_REQUIRED409NoCatalogErrorThe store has not accepted the Product Catalog extension version this feature needs.
CART_ITEMS_INVALID422NoCartLineErrorSome cart lines cannot be bought; items holds a code (and maxQuantity) per line.
CART_REJECTED502NoCatalogErrorThrottle refused to create the cart; error.throttleCode is its reason.
CART_UNAVAILABLE503YesCatalogErrorThrottle could not be reached to create the cart.
CHECKOUT_UNAVAILABLE503YesCatalogErrorLegacy checkout route: Throttle could not be reached.
RETURN_URL_NOT_ALLOWED422NoCatalogErrorA return URL is not on Checkout settings › Allowed return websites; one issue per field.
CHECKOUT_TOTAL_TOO_LARGE422NoCatalogErrorLegacy checkout route: the cart total is too large.
CHECKOUT_CART_MISMATCH409NoCatalogErrorLegacy checkout route: the Throttle cart did not match the request. Start a new checkout.
CHECKOUT_ITEMS_INVALID422NoCatalogErrorLegacy checkout route: some lines cannot be bought.
CHECKOUT_REJECTED502NoCatalogErrorLegacy checkout route: Throttle refused the checkout.
CURRENCY_NOT_ENABLED400NoCatalogErrorThe currency parameter names a currency the store does not sell in.
STOREFRONT_INDEXING503YesCatalogErrorThe catalog is building its first storefront index. Retry-After: 30.
EMAIL_NOT_VERIFIED403NoCatalogErrorReviews: the shopper has not verified their email address.
NOT_VERIFIED_BUYER403NoCatalogErrorReviews: only verified buyers may review, and the shopper has no paid order for the product.
PURCHASE_REFUNDED403NoCatalogErrorReviews: the order behind the purchase no longer counts (refunded or cancelled).
ALREADY_REVIEWED409NoCatalogErrorReviews: one review per customer and product.
REVIEW_QUEUE_FULL429YesRateLimitedErrorReviews: 500 reviews already wait for approval. Retry-After: 900.
REVIEW_VERIFICATION_UNAVAILABLE503YesCatalogErrorReviews: the purchase could not be confirmed with Throttle. Nothing was saved.

Throttle's cart-session codes (quantity_unavailable, cart_prices_changed, cart_session_expired and the others) reach you with source: 'throttle'; they are listed on Cart and checkout.

cart_session_expired carries an unchecked URL
Throttle's cart_session_expired passes details.storefrontUrl through as it was stored, unvalidated. Never render it as a link or navigate to it without your own check: http or https only, on an origin you trust.

Rate limits

Key typePer visitor (IP)Per key, all visitors
Browser, app and legacy300 requests a minute; 600 for suggestions12,000 requests a minute
ServerNone60,000 requests a minute
  • Review submission adds 5 attempts a minute per customer and 10 accepted reviews an hour per IP address (server keys skip the IP cap).
  • Over a limit the answer is 429 RATE_LIMITED with Retry-After in seconds. Wait that long; the SDK exposes it as RateLimitedError.retryAfterSeconds.
  • The client never retries a 429 itself, so a burst never turns into a retry storm.
  • The per-visitor count is kept per Cloudflare location and is approximate: a fast burst can pass somewhat more than the limit before it is refused. The per-key limit still caps a key.

In the hooks. A catalog 429 RATE_LIMITED that a read hook or a useCart change meets pauses every read of its CatalogProvider until retryAfterSeconds:

  • The hook that met it keeps its data and shows the RateLimitedError.
  • A hook mounted during the pause makes no request. With nothing cached for its query, it shows a RateLimitedError with the seconds left, not a loading state; with a cached answer, it shows that.
  • useCart and useSubmitReview actions reject at once with that error, with no request.
  • When the pause ends, each mounted query fetches once.
  • Every other 429 goes to the caller only and pauses nothing: a 429 from Throttle's cart session, and every 429 from a review submit (RATE_LIMITED from the per-customer limit or the per-IP review cap, and REVIEW_QUEUE_FULL).

While the catalog is indexing

When a store first turns on the storefront, the catalog builds its storefront index. Until it is done, plain product browsing answers 200 with indexing: true (no filters, and a null total), and requests that need the index answer 503 STOREFRONT_INDEXING with Retry-After: 30. See the first backfill.

  • The client retries a read once after Retry-After when that is 30 seconds or less (indexingRetry: { maxWaitSeconds } changes it, false turns it off); otherwise it throws with retryAfterSeconds.
  • The hooks never wait inside a call. They show the 503 and fetch once more at Retry-After; a list answered with indexing: true is fetched once more after 30 seconds.