Error Handling
App Bridge errors fall into three categories:
- Transport errors — the host never responds. The SDK rejects with a
timeout after 10 seconds.
- 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.
- 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 discriminator field (
cancelled: true,
empty selection, etc.). 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:
{
type: 'APP_BRIDGE_RESPONSE',
action: 'TOAST_SHOW',
id: 'ab_173456789_42',
payload: {},
error: 'Toast queue full', // optional — when present, the SDK rejects
}
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:
try {
const { selection } = await app.dispatchAndWait('RESOURCE_PICKER_OPEN', {
resourceType: 'product',
});
// host returned successfully — selection may be [] if user cancelled
} catch (err) {
// host returned `error:` or the call timed out
console.error('Picker failed:', err.message);
}
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:
- 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.
- 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.
- 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:
async function dispatchWithRetry(action, payload, attempts = 2) {
for (let i = 0; i < attempts; i++) {
try {
return await app.dispatchAndWait(action, payload);
} catch (err) {
if (i === attempts - 1) throw err;
if (!/timeout/.test(err.message)) throw err; // only retry timeouts
await new Promise((r) => setTimeout(r, 100 * (i + 1)));
}
}
}
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:
const off = app.subscribe('CART_UPDATED', (payload, error) => {
if (error) {
console.warn('cart update failed:', error);
return;
}
setCart(payload);
});
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.
Modal / ConfirmationModal
const { action } = await Modal.create(app, options).dispatch();
// action: 'primary' | 'secondary' | 'dismissed'
A modal always resolves — even when the user clicks the backdrop or
hits Escape (action === 'dismissed'). Treat dismissed as a normal
cancel, not an error. A timeout here means 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
const { cancelled, selection } = await ResourcePicker.create(app, options).dispatch();
cancelled: true with selection: [] is the user-cancel signal — not an
error. Check the discriminator before treating an empty selection as a
problem:
const result = await picker.dispatch();
if (result.cancelled) return; // user closed the picker
if (result.selection.length === 0) { // empty result — unusual
Toast.warning(app, 'No matching items');
return;
}
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.
try {
const text = await Clipboard.read(app);
parseAndPrefillForm(text);
} catch (err) {
if (/denied|permission/i.test(err.message)) {
Toast.warning(app, 'Allow clipboard access to paste.');
} else {
throw err;
}
}
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:
{ payload: {}, error: 'verify-cart blocked: out of stock' }
…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.
removeDiscountCode and addGiftCard are stubbed today — they resolve
with { ok: false, applicable: false } instead of throwing. Inspect
applicable before assuming success.
METAFIELD_CHANGE on the checkout host is reserved for forward
compatibility and currently rejects with error: 'cart metafields not supported yet'.
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.
journey.intercept(async () => {
try {
const ok = await checkInventory();
return ok ? { behavior: 'allow' } : {
behavior: 'block',
reason: 'One of your items just went out of stock — please reload.',
};
} catch (err) {
console.error('intercept failed:', err);
return { behavior: 'allow' }; // explicit fallback
}
});
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:
try {
const { products } = await api.fetchJson('/api/v1/products?limit=50');
} catch (err) {
// err.message: "RestApi: 429 Too Many Requests — { retryAfter: 30 }"
}
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:
try {
const result = await intents.launchAndWait('refunds-app.issue-refund', { orderId });
if (result.error) {
Toast.error(app, `Couldn't open refunds: ${result.error}`);
return;
}
if (result.cancelled) return;
toast.success('Refund issued');
} catch (err) {
// Network / host wiring failure — fall back to a manual redirect
Redirect.create(app, { url: '/admin/orders/' + orderId }).dispatch();
}
The launched extension can return either { error: '...' } (host could
not resolve the target — typically an unknown handle) or
{ cancelled: true } (user closed the launched modal without
completing).
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:
try {
const token = await app.getSessionToken();
// …
} catch (err) {
setSessionInvalid(true); // your UI shows a "Reconnect" button
}
The wrapper caches tokens until 30s before their JWT exp claim, so a
single failure doesn’t trigger a retry storm.
Error reference
| Source | Symptom | Cause | Recovery |
|---|
dispatchAndWait | Error('App Bridge: timeout waiting for X response') | Host doesn’t wire X or hasn’t mounted | Feature-gate, retry once, fall back |
dispatchAndWait | Error('<host reason>') | Host returned error: field | Show user-friendly message |
subscribe(action, cb) | cb(payload, error) with error populated | Host emitted error for the channel | Ignore payload, render fallback |
Modal.dispatch() | resolves with action: 'dismissed' | User closed without clicking | Treat as cancel |
ResourcePicker.dispatch() | resolves with cancelled: true | User closed picker | Treat as cancel |
Clipboard.read() | Error('clipboard read denied') | Permissions Policy or user denial | Prompt user to allow paste |
Cart.apply* | Error('verify-cart blocked: ...') | Function / merchant rule blocked mutation | Re-fetch cart, show reason |
BuyerJourney.intercept callback throws | Defaults to allow | Your code errored | Log + add explicit fallback |
RestApi.fetchJson | Error('RestApi: 4xx ... — <body>') | Backend rejected the request | Inspect status, retry / surface |
app.getSessionToken() | rejects | Host couldn’t sign a JWT | Show reconnect CTA, do not retry |
Patterns
Show toast on transient failure, propagate on persistent
import { Toast } from '@launchmystore/app-bridge';
async function save() {
try {
await api.fetchJson('/api/v1/widgets', { method: 'POST', body: form });
Toast.success(app, 'Saved');
} catch (err) {
if (/^RestApi: 5\d\d/.test(err.message)) {
Toast.warning(app, 'Server hiccup — try again');
return;
}
Toast.error(app, `Save failed: ${err.message}`);
throw err; // let an error boundary handle it
}
}
Gracefully degrade when an action isn’t wired
import { Features, APP_BRIDGE_FEATURES } from '@launchmystore/app-bridge';
const { features } = await Features.create(app).query();
const canPickResources = features.includes(APP_BRIDGE_FEATURES.RESOURCE_PICKER);
if (canPickResources) {
return <button onClick={picker.open}>Pick product</button>;
}
return <input placeholder="Product ID" onChange={…} />;
Surface BuyerJourney intercept failures without trapping the buyer
journey.intercept(async () => {
try {
const ok = await validateCart();
return ok ? { behavior: 'allow' } : { behavior: 'block', reason: 'Cart invalid.' };
} catch (err) {
Sentry.captureException(err);
Toast.error(app, 'Validation failed — proceeding without check.');
return { behavior: 'allow' };
}
});
See Also