Error recovery
From a thrown SDK error or `success: false` envelope to the right user-facing recovery — retry, fallback, or report.
The Error model page covers what each error means. This page covers what to do about it — concrete recovery code per failure mode, plus a retry helper you can paste in.
If you're new, read the model first. Otherwise, treat this as your copy-paste reference.
TL;DR — the dispatcher
import { SDKError } from 'i99dash';
async function safeCall<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
try {
return await fn();
} catch (e) {
if (!(e instanceof SDKError)) throw e;
switch (e.code) {
case 'NOT_INSIDE_HOST':
case 'CAR_STATUS_UNAVAILABLE':
case 'MEDIA_UNAVAILABLE':
case 'CLIMATE_UNAVAILABLE':
case 'CONNECTIVITY_UNAVAILABLE':
case 'LOCATION_UNAVAILABLE':
case 'NAVIGATION_UNAVAILABLE':
case 'SYSTEM_UNAVAILABLE':
case 'VEHICLE_DIAGNOSTICS_UNAVAILABLE':
case 'VEHICLE_ENVIRONMENT_UNAVAILABLE':
return fallback;
case 'BRIDGE_TIMEOUT':
case 'BRIDGE_TRANSPORT':
return retry(fn, fallback); // see below
case 'INVALID_RESPONSE':
report(e); // version drift — log, don't retry
return fallback;
case 'CAR_STATUS_QUOTA_EXCEEDED':
report(new Error('subscription leak: ' + e.message));
return fallback;
default:
throw e;
}
}
}The rest of this page is what each branch should do, why, and the shape of the retry helper.
Per-code recovery recipes
NOT_INSIDE_HOST — render fallback, never retry
The bridge isn't on window. SSR, Storybook, jsdom, or your laptop
browser. Permanent for this runtime; retrying does nothing.
import { createClientOrSSR } from 'i99dash';
const client = createClientOrSSR(); // null outside the host
if (!client) {
return <p>Open this app from your i99dash head unit.</p>;
}Most code reaches for createClientOrSSR
instead of catching this error. Branch on null once at the top of
the tree and the rest of your code stops worrying about it.
*_UNAVAILABLE — feature-detect with has(), render fallback
Per-family unavailability:
CAR_STATUS_UNAVAILABLEMEDIA_UNAVAILABLECLIMATE_UNAVAILABLEVEHICLE_DIAGNOSTICS_UNAVAILABLEVEHICLE_ENVIRONMENT_UNAVAILABLESYSTEM_UNAVAILABLECONNECTIVITY_UNAVAILABLELOCATION_UNAVAILABLENAVIGATION_UNAVAILABLE
Older host without the family. Permanent for this host version.
Don't retry — pre-flight with client.has() instead, or use a React
hook (which suppresses the throw and stays on fallback).
if (await client.has('media.read')) {
renderNowPlaying();
} else {
renderPlaceholder();
}See the Capability detection guide.
BRIDGE_TIMEOUT — retry once with backoff
The host bridge didn't respond inside 10 s (default). Usually means the host is busy; sometimes means the host hung. Retry once — beyond that, surface "device unresponsive" to the user.
import { BridgeTimeoutError } from 'i99dash';
async function retry<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
for (let attempt = 1; attempt <= 2; attempt++) {
try {
return await fn();
} catch (e) {
if (!(e instanceof BridgeTimeoutError)) throw e;
if (attempt === 2) {
report(e);
return fallback;
}
await new Promise((r) => setTimeout(r, 500 * attempt));
}
}
return fallback;
}error.operation tells you which call timed out ('getContext',
'car.read', 'subscribeCarStatus', etc.). Useful for
the "what specifically hung" telemetry tag.
BRIDGE_TRANSPORT — retry with exponential backoff
The bridge layer crashed mid-call — host bug, almost always transient. Retry up to 3 times with exponential backoff (200 / 400 / 800 ms). Persistent transport failure is the same UX as a timeout.
async function retryTransport<T>(fn: () => Promise<T>): Promise<T> {
let last: unknown;
for (let i = 0; i < 3; i++) {
try {
return await fn();
} catch (e) {
last = e;
if (!(e instanceof BridgeTransportError)) throw e;
await new Promise((r) => setTimeout(r, 200 * 2 ** i));
}
}
throw last;
}error.cause holds the underlying transport error. Forward it to
your reporter — Sentry walks the cause chain automatically.
INVALID_RESPONSE — log and stop
The host pushed a payload that doesn't match the SDK schema. Means
host/SDK version drift. Retrying produces the same broken payload.
Log with error.cause (the underlying zod error) so the team can
fix the host or pin the SDK.
import { InvalidResponseError } from 'i99dash';
if (e instanceof InvalidResponseError) {
reporter.captureException(e, { extra: { cause: e.cause } });
return fallback;
}This is rare in production. If you see it spike, the host shipped a new field or changed an enum and the SDK consumer hasn't caught up.
CAR_STATUS_QUOTA_EXCEEDED — find the leak
You opened more than 10 concurrent client.car.onStatusChange subs
from this mini-app. Almost always a missing useEffect cleanup.
Retrying doesn't help — the new sub will throw too.
// ❌ Subscribes on every render — leaks one per render
function Widget() {
client.car.onStatusChange(setStatus); // never cleaned up
return <span>...</span>;
}
// ✓ Holds one sub for the component's lifetime
function Widget() {
useEffect(() => client.car.onStatusChange(setStatus), [client]);
return <span>...</span>;
}See the Subscriptions guide for the diagnostic checklist.
External API failures are plain fetch() errors
External HTTPS calls don't go through the SDK — you use fetch()
against an origin you declared in manifest.network. So their failures
are ordinary web failures, not SDKErrors: branch on res.ok, and
catch the rejection.
try {
const res = await fetch('https://api.example.com/v1/x');
if (!res.ok) {
if (res.status >= 500) return retryWithBackoff(); // 5xx — transient
return showError(`HTTP ${res.status}`); // 4xx — surface it
}
return await res.json();
} catch {
// fetch() rejects with TypeError on a network failure OR a CSP block
// (the origin isn't in manifest.network). Same handling for the user.
return showOffline();
}The one platform-specific gotcha: a TypeError can mean "origin not
declared", not just "offline". If a call fails only on-car but works
in a browser tab, add the origin to manifest.network. See
Calling an external API.
Observability — what to send to your reporter
import { SDKError } from 'i99dash';
function report(e: unknown) {
if (e instanceof SDKError) {
reporter.captureException(e, {
tags: { sdkCode: e.code },
extra: { cause: 'cause' in e ? e.cause : undefined },
});
return;
}
reporter.captureException(e);
}Tag exceptions with sdkCode so you can filter dashboards by failure
mode. The cause chain is part of ES2022 Error and most modern
loggers walk it automatically — pass it through, don't redact.
What's worth a separate alert vs. a dashboard:
| Code | Alert? | Dashboard? |
|---|---|---|
BRIDGE_TIMEOUT | If sustained rate > 1% of calls | Always |
BRIDGE_TRANSPORT | If sustained rate > 0.5% — host bug | Always |
INVALID_RESPONSE | Yes, page someone — version drift | Always |
CAR_STATUS_QUOTA_EXCEEDED | Yes — your code has a leak | Always |
*_UNAVAILABLE codes | No — expected on older firmware | Trend over time |
NOT_INSIDE_HOST | No — expected outside the host | No |
React: hooks already do most of this
Every hook in i99dash/react suppresses
the per-family unavailability errors and stays on fallback. You
don't write capability checks for hook-driven code:
const { data: media, error } = useMedia({ fallback: silentMedia });
// `error` is null when MEDIA_UNAVAILABLE; non-null on real bugs.Use the recipes above when you call the underlying methods directly
(client.media.getSnapshot() etc.) — typically inside a one-off
useEffect or a non-React surface.
Don't do these
- Don't
try/catchand returnnull. That hides a leaking sub or a version-drift bug. Catch, branch oncode, return the right fallback for that specific code, report the rest. - Don't retry
INVALID_RESPONSE. The host returns the same broken payload until someone deploys a fix. - Don't retry
*_UNAVAILABLE. The host doesn't grow a family mid-session. - Don't retry a
fetch()CSP block. ATypeErrorfrom an undeclared origin won't fix itself — add the origin tomanifest.networkand rebuild. - Don't put
report(e)inside the retry loop. You'll spam your reporter on every failed attempt. Report after the loop exhausts.
Related
- Error model — the mental model behind these recipes.
- Errors reference — every error code and the class that wraps it.
- Capability detection — pre-flight
has()so you don't catch*_UNAVAILABLEat all. - Best practices — anti-patterns from production incidents.
Location + heading
How to source position + heading in v5 — the per-family `client.location` controller was removed; the unified `client.car` catalog will expose location signals in v5.1. Until then, use `navigator.geolocation` directly. Plus a heading/compass recipe.
Testing
How to test mini-app code with `MiniAppClient.withBridge()` — the one seam you need.