Today WebView Bridge
Contract

Error Handling

When the host rejects, how errors are encoded on the wire, and how the web maps them.

On the wire

The two transports encode errors out-of-band rather than as a thrown JS value, so a rejection is a string, not a structured object.

  • iOSWKScriptMessageHandlerWithReply calls replyHandler(nil, errorMessage). The non-nil errorMessage rejects the web-side Promise; its string becomes the rejection reason.
  • Android — the injected shim rejects its returned Promise with an error string supplied by the host.

A host MUST provide a human-meaningful, non-empty error string when it rejects. It SHOULD NOT leak secrets or raw stack traces into that string.

Which capabilities reject

CapabilityRejects?When
trackNoBest-effort; resolves undefined regardless of delivery
headersNo"Nothing to inject" is {}, not an error
refreshTokenYesTerminal auth failure (expired/revoked refresh token, signed out)
unknown typeNoResolves undefined

In practice refreshToken is the only v1 capability that rejects under normal operation. The host-unavailable case (no channel on window) is surfaced by the SDK, not the wire.

How the SDK maps errors

The web client normalises every failure into a BridgeError or one of its subclasses:

export class BridgeError extends Error {}

// No native channel on window (desktop browser, non-app WebView):
export class BridgeUnavailableError extends BridgeError {}

// Host replied, but the payload did not match the capability's result shape:
export class BridgeProtocolError extends BridgeError {}
Error classCauseForce re-auth?
BridgeUnavailableErrorNo native channel on windowNo — no host
BridgeErrorHost rejected (terminal auth failure)Yes
BridgeProtocolErrorHost replied with a malformed / empty payloadNo — host bug
  • A wire rejection (the host rejected) becomes a BridgeError whose message is the host-supplied string. For refreshToken this is a terminal auth failure — re-authenticate the user.
  • A missing host becomes a BridgeUnavailableError. getHeaders does not throw it (it resolves {}); refreshToken and a forced fetch retry surface it.
  • A malformed reply — the host resolved, but the value did not match the expected result shape (e.g. refreshToken returning a value without a string authorization, or the undefined an untrusted-surface or sub-frame caller receives) — becomes a BridgeProtocolError. This is a protocol/serialization bug on the native side or a denied trust gate, not a signed-out user. Callers MUST NOT force re-authentication on it.

Patterns

Refresh + retry once. A second 401 is terminal — surface it, do not loop.

try {
  const { authorization } = await bridge.refreshToken()
  return await retryWith(authorization)
} catch (e) {
  if (e instanceof BridgeUnavailableError) {
    // no host — fall back to the web sign-in flow
  } else if (e instanceof BridgeProtocolError) {
    // malformed reply — a host bug, not a signed-out user. Do NOT re-auth.
  } else {
    // terminal auth failure — send the user to re-authenticate
  }
  throw e
}

Never let track reject. Fire-and-forget means the web swallows any rejection so it cannot become an unhandledrejection:

bridge.track('event_name', params) // returns void; the SDK catches internally

Distinguish "no host" from "real failure". Branch on BridgeUnavailableError when the two cases need different UX — local development (no host) versus a genuine auth failure in the app.

On this page