> ## Documentation Index
> Fetch the complete documentation index at: https://docs.launchmystore.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Error Handling

> How App Bridge errors propagate, common failure modes, and recovery patterns

# 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 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:

```ts theme={null}
{
  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`:

```ts theme={null}
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);
}
```

<Warning>
  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.
</Warning>

## 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:

```ts theme={null}
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:

```ts theme={null}
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

```ts theme={null}
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

```ts theme={null}
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:

```ts theme={null}
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.

```ts theme={null}
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:

```ts theme={null}
{ 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.

```ts theme={null}
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:

```ts theme={null}
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:

```ts theme={null}
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:

```ts theme={null}
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

```ts theme={null}
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

```ts theme={null}
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

```ts theme={null}
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

* [Actions Reference](/app-bridge/actions) — payload + response shapes for
  every action class.
* [App Bridge for Checkout](/app-bridge/checkout) — host capability table
  for the checkout iframe context.
* [Post-Purchase Bridge](/app-bridge/post-purchase-bridge) — the subset
  available after `DONE`.
* [React Hooks](/app-bridge/react-hooks) — hook return shapes including
  `loading` / `error` fields.
