i99dash docs
Guides

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_UNAVAILABLE
  • MEDIA_UNAVAILABLE
  • CLIMATE_UNAVAILABLE
  • VEHICLE_DIAGNOSTICS_UNAVAILABLE
  • VEHICLE_ENVIRONMENT_UNAVAILABLE
  • SYSTEM_UNAVAILABLE
  • CONNECTIVITY_UNAVAILABLE
  • LOCATION_UNAVAILABLE
  • NAVIGATION_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:

CodeAlert?Dashboard?
BRIDGE_TIMEOUTIf sustained rate > 1% of callsAlways
BRIDGE_TRANSPORTIf sustained rate > 0.5% — host bugAlways
INVALID_RESPONSEYes, page someone — version driftAlways
CAR_STATUS_QUOTA_EXCEEDEDYes — your code has a leakAlways
*_UNAVAILABLE codesNo — expected on older firmwareTrend over time
NOT_INSIDE_HOSTNo — expected outside the hostNo

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/catch and return null. That hides a leaking sub or a version-drift bug. Catch, branch on code, 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. A TypeError from an undeclared origin won't fix itself — add the origin to manifest.network and rebuild.
  • Don't put report(e) inside the retry loop. You'll spam your reporter on every failed attempt. Report after the loop exhausts.

On this page