Skip to main content

Error Handling

App Bridge errors fall into three categories:
  1. Transport errors — the host never responds. The SDK rejects with a timeout after 10 seconds.
  2. Host-reported errors — the host runs your action but the underlying operation fails. The SDK rejects with an Error carrying the host’s reason string.
  3. User-cancellation — the host returns a successful response whose payload signals the user dismissed the prompt. These are not thrown; they resolve normally with a cancel signal (an empty selection, a close/cancel event on the action class, etc. — Scanner is the exception: its capture() rejects on cancel). Treat them as a normal branch.
Knowing which category you’re in determines whether to retry, surface a toast, or render an empty state.

How errors flow through dispatchAndWait

The wire format for an error response is:
When error is present on the response, the SDK rejects the promise with new Error(response.error) so callers can use a standard try / catch:
On error, the host’s error field is forwarded to the callback, and dispatchAndWait translates that into a rejection. Subscriptions created via subscribe() also receive the error as the second argument of the callback — handle it explicitly when subscribing.

Timeouts

dispatchAndWait rejects with Error('App Bridge: timeout waiting for <ACTION> response') after 10 000 ms if the host never responds. The timeout is hard-coded; you cannot tune it per call. Three things commonly cause a timeout:
  1. Action is unsupported on the current host. The admin host doesn’t wire CART_LINES_CHANGE; the checkout host doesn’t wire RESOURCE_PICKER_OPEN; the post-purchase host doesn’t wire MODAL_OPEN. Use the host capability table in each host’s reference page to feature-gate calls.
  2. The host hasn’t mounted yet. Checkout extensions sometimes dispatch before the checkout extension slot finishes mounting. Wait for BRIDGE_PING once before issuing real calls.
  3. A typo in the action name. Action names are case-sensitive and the host silently drops unknown names. 'TOAST_SHO' will time out ten seconds later — there is no synchronous “unknown action” error.
Recovery pattern:
Do not retry user-driven actions (modals, pickers, prints) — the user has already moved on. Only retry data-fetch RPCs (USER_FETCH, CONFIG_FETCH, ENVIRONMENT_FETCH, CART_GET, etc.) where a missed response means stale state.

Subscription errors

app.subscribe(action, callback) forwards the host’s error string as the second argument:
Forgetting the second parameter means error responses look like normal data — your component will then read the empty payload ({}) and silently render an empty cart. Always destructure (payload, error).

Per-action failure modes

Toast

Toast.create(app, { … }).dispatch() is fire-and-forget — it doesn’t return a promise, so transport failures are invisible. If the toast must succeed (regulatory notice, payment receipt), use dispatchAndWait('TOAST_SHOW', …) and surface a fallback in catch. The admin host reads payload.type; the checkout host reads payload.variant. The Toast helper sends type; in checkout iframes build the payload manually.
A dismissal (backdrop click / Escape) fires the close event — treat it as a normal cancel, not an error. If no event ever fires, the host never opened the modal at all — usually because the iframe is in a context that doesn’t support modals (checkout, post-purchase).

Resource Picker

An empty selection array is the user-cancel signal — not an error (there is no cancelled field on the payload; class consumers get the dedicated cancel event instead). A rejected promise from the picker usually means the host doesn’t support the requested resourceType for that surface — admin-only types (order, customer, etc.) on a non-admin host will time out.

Clipboard

Clipboard.write() runs iframe-side and silently falls back to the legacy document.execCommand('copy') path when the modern API throws or is unavailable. The promise still resolves either way — there is no indication to the caller that the legacy path ran. Clipboard.read() (and the paste() alias) does reject when the host denies the request — Chrome blocks clipboard-read for cross- origin iframes, so the SDK delegates to the host, and the host can reject if the user denies the permission prompt or the focused window isn’t authorised. Errors arrive as Error('clipboard read denied') or similar.

Cart (checkout / post-purchase)

Cart.applyCartLinesChange, applyDiscountCodeChange, applyNoteChange, applyAttributeChange, applyMetafieldChange all throw when the host’s verify-cart pipeline rejects the mutation. Common reasons:
  • A cart_transform or order_validation function blocks the change.
  • The merchant’s discount rules reject the code (expired, min-cart not met, mutually exclusive with another active discount).
  • The line variant is sold out or no longer purchasable.
The host returns:
…which the SDK forwards as new Error('verify-cart blocked: out of stock'). Always wrap cart mutations in try / catch and re-fetch the cart (CART_GET) on failure so your UI mirrors host state. Gift cards are live on the checkout host: addGiftCard and removeGiftCard (via GIFT_CARD_CHANGE) resolve with { ok: true, code } on success and reject with the host’s error message on failure. removeDiscountCode is not wired — it does not resolve { ok: false }, it rejects with Error('not supported yet'). Wrap it in try / catch if you attempt it. (addDiscountCode is live.) METAFIELD_CHANGE is fully wired on the checkout host: it accepts { type: 'updateMetafield' | 'removeMetafield', namespace, key, ... } (plus set/remove aliases) and rejects with 'namespace and key required' or 'unsupported METAFIELD_CHANGE type "<type>"' on bad payloads.

BuyerJourney

The intercept callback can be sync or async. If it throws, the SDK defaults to behavior: 'allow' so the buyer is never trapped by a broken extension. Watch your console — silent intercept failures cause your business rule to be bypassed.

RestApi

RestApi.fetch() reuses the standard Fetch API — it does not throw on non-2xx. Inspect response.ok yourself, or use fetchJson() which rejects on non-2xx with a body-aware message:
Two distinctive failures:
  • 'RestApi: host CONFIG_GET did not return apiBase' — the host responded but the payload lacks apiBase. The SDK clears its config cache so the next call retries. Verify the host implements CONFIG_GET.
  • 'App Bridge: timeout waiting for SESSION_TOKEN_REQUEST response' — the host never returned a JWT. Most often happens when the iframe is in a host that doesn’t authenticate apps (checkout, post-purchase).

Intents

Intents.launch() is fire-and-forget — it returns the postMessage id synchronously and never throws (beyond the empty-target guard). Intents.launchAndWait() rejects on timeout or when the target doesn’t resolve — a missing target never resolves with an { error } payload, and there is no { cancelled: true } response:
The target is an admin-extension target string (e.g. admin.product-details.action.my-action). Handle every failure in the catch — do not test the resolved value for error or cancelled fields; they don’t exist.

Session Token

app.getSessionToken() and useSessionToken().getToken() both throw when the host rejects the JWT request — typically because the API key / shop combination is invalid. Catch once at the top of your data layer and surface a “reconnect” CTA:
The wrapper caches tokens until 30s before their JWT exp claim, so a single failure doesn’t trigger a retry storm.

Error reference

Patterns

Show toast on transient failure, propagate on persistent

Gracefully degrade when an action isn’t wired

Surface BuyerJourney intercept failures without trapping the buyer

See Also