Skip to content

Error handling

The SDK never uses string errors, never silently swallows a failure, and never returns a bare Promise<boolean> for something that can fail in more than one way. Instead it splits errors across three complementary channels over one class tree. This page explains the channels, then documents what to do about each code — the generated API reference documents the types; this page documents recovery.

Every failure is a ConsoleSdkError subclass carrying a namespaced, string-literal code. Codes are greppable and exhaustive, and each subclass narrows the union to its namespace, so both instanceof checks and code matching narrow correctly:

import {
ConsoleSdkError, // abstract base
ScanError, // 'ble/*'
ConnectError, // 'connect/*' (+ target, conflictingDeviceId)
CommandError, // 'command/*' (+ command)
SessionError, // 'session/*' | 'record/*' (+ armed)
FirmwareError, // 'firmware/*'
OutsideProviderError, // 'sdk/outside-provider'
isConsoleSdkError,
} from "@rogue/console-sdk";

The base carries an optional deviceId (present whenever the failure is attributable to a device) and an optional cause (the underlying ble-plx / fetch error — never a ble-plx type publicly). Subclasses add decision-carrying fields: ConnectError.conflictingDeviceId is set exactly when code === 'connect/modality-conflict', so you can render “swap machines?” in one pass; SessionError.armed lists which devices armed before a rollback.

Guard functions exist for each subclass (isConnectError, isSessionError, isCommandError, isFirmwareError, isScanError, isOutsideProviderError) plus the umbrella isConsoleSdkError.

Channel 1 — reads: failure is a state you render

Section titled “Channel 1 — reads: failure is a state you render”

Live-state hooks (useDevices, useLiveMetrics, useWorkoutSession) are not request-shaped. Their “errors” live in the domain state itself and are rendered next to live content, never thrown to blow away the screen:

  • A dropped connection is device.connection.status === 'reconnecting' / 'disconnected', with cause on the disconnected state.
  • A failed workout is session.phase === 'failed', carrying a typed SessionError in session.error — and an aborted one is session.phase === 'aborted' with a typed cause.

You branch on status / phase; failure is a state, not an exception.

const session = useWorkoutSession();
if (session.phase === "failed") {
// session.error is a typed SessionError
showBanner(session.error.code);
}

The one request-shaped read in the SDK (useFirmwareStatus) returns a discriminated QueryResult<T> whose error variant keeps the last-known data (stale-while-error), so a failed manifest refresh never blanks a firmware card.

Every imperative act comes as a pair, mirroring TanStack’s mutate/mutateAsync split:

  • The bare verb never rejects. connect, start, end, setDisplayUnits, and friends resolve a success value or a falsy sentinel (Device | null, WorkoutResult | null, boolean) and route the typed failure to the state channel — the hook’s error/errors field, the session snapshot (startError, phase 'failed'), or DEV-warning + onError for plain action hooks. This is what makes onPress={() => connect(item)} and onPress={pause} safe: a fire-and-forget handler whose failure is already rendered from state can never float an unhandled rejection.
  • The *Async twin (connectAsync, startAsync, endAsync, …) returns a Promise that rejects with the same member of the hierarchy, for imperative composition — sequenced flows, NFC/QR entry points, non-React utilities.
// state style (the default — fire-and-forget from event handlers)
const { connect, errors } = useConnectDevice();
// <Button onPress={() => connect(item)} /> — failure renders via `errors`
// imperative style (the *Async twin)
try {
await connectAsync(id);
} catch (e) {
if (isConnectError(e) && e.code === "connect/timeout") retryUi();
}

Mutation hooks expose isPending alongside (the React 19 action idiom), and reset() clears mutation error state for real.

config.onError(error, context) fires once per failure (not once per subscribed component), for cross-cutting toasts, telemetry, and crash reporting. It complements — never replaces — the state channels:

const config = createConsoleConfig({
onError: (error, context) => {
analytics.track("sdk_error", {
code: error.code,
deviceId: error.deviceId,
});
},
});

The two documented non-failures are excluded from onError (below).

Cancelling something the user asked to cancel is a normal outcome, not a failure:

  • Firmware cancel() produces a distinct status: 'cancelled' variant with error: null — a state, not a thrown error.
  • An aborted connect() rejects connectAsync with 'connect/cancelled' only because a promise must settle. It is documented as a non-failure and excluded from onError. Treat it as a no-op in your catch.
  • 'session/console-stopped' is not a fault either — it is the premature-FINISH (“the athlete pressed HOME on the console”) heuristic surfaced as a first-class, matchable outcome rather than a silent guess. It arrives as session.phase === 'aborted' with cause: 'console-stopped'.

Every code, and what to do about it. Match on error.code (or narrow with a guard first).

ble/* — Bluetooth adapter & permissions (ScanError)

Section titled “ble/* — Bluetooth adapter & permissions (ScanError)”
Code What happened Recovery
ble/unsupported The device has no BLE radio Render a “not supported” state; hide connect UI. Equivalent to useBluetoothState() === 'unsupported'
ble/unauthorized BLE permission was denied Prompt to open Settings; re-check via useBlePermissions(). Gate connect screens behind the permission state
ble/powered-off Bluetooth is turned off Ask the user to turn Bluetooth on; the scan resumes when useBluetoothState() returns 'on'
ble/scan-failed The platform scan call failed Transient — stop and restart the scan (unmount/remount useDeviceScan), or surface a retry

Prefer the reactive useBluetoothState / useBlePermissions hooks over catching these — a connect screen usually gates on state and rarely sees a thrown ScanError.

connect/* — connection (ConnectError, carries target)

Section titled “connect/* — connection (ConnectError, carries target)”
Code What happened Recovery
connect/timeout The connect attempt timed out Offer a retry; the device may be out of range or asleep
connect/not-found An awaited { consoleId } / { nearest } target never appeared Tell the user to bring the console closer / wake it; retry the scan-and-connect
connect/incompatible-device The device is not a supported Rogue console Nothing to retry — filter it out of the list
connect/device-limit The fleet is at its configured maxDevices Ask the user to disconnect one first, or raise fleet.maxDevices in config
connect/modality-conflict A device of that modality is already connected under onePerModality Read error.conflictingDeviceId and offer a one-tap “use this machine instead” swap
connect/cancelled The connect was aborted (non-failure) No-op — the user cancelled. Excluded from onError

command/* — per-console commands (CommandError, carries command)

Section titled “command/* — per-console commands (CommandError, carries command)”
Code What happened Recovery
command/not-connected The command targeted a non-ready device Re-check device.isConnected; wait for connection.status === 'ready'. Bare verbs make this a dev-warned no-op
command/unsupported The console/firmware does not support the command Feature-gate the control by model/firmware; hide it
command/timeout The console did not acknowledge in time Retry once; if it persists the link is degraded
command/rejected The console rejected the command The console is in a state that disallows it — reflect the current state and let the user retry
command/busy The console is processing another command Back off briefly and retry, or debounce the control

setDisplayUnits and other console controls are bare verbs — a failure is dev-warned and routed to onError, so a picker’s onChange is safe fire-and-forget. Reach for the *Async twin only when you must await the write.

session/* and record/* — workout session (SessionError)

Section titled “session/* and record/* — workout session (SessionError)”
Code What happened Recovery
session/invalid-plan The StartWorkoutInput (or roleMap) is invalid — a mode/role mismatch (a role-scoped plan with no mode, a broadcast plan scoping a role, or a sequential plan missing a role), an expanded program past the firmware leg/round ceiling, 'unlimited' on a multi-interval circuit, or two devices of one modality Fix the plan before arming; catch it from roleMap to offer a “which machine?” picker
session/arm-failed Some devices failed to arm; the start rolled back Read error.armed for what armed before rollback; surface which machine failed and retry
session/already-active start() was called while a session is arming/running/resting/paused/finalizing Finish or abandon() the current session first (a terminal session is dismissed implicitly)
session/wrong-phase An *Async verb was called in a phase that disallows it Guard on session.phase; the bare verb makes this a dev-warned no-op instead
session/console-stopped The athlete pressed HOME on a console (non-failure) Not a fault — arrives as aborted / cause: 'console-stopped'; render the partial result
session/devices-lost Every session device’s link was exhausted Arrives as aborted / cause: 'devices-lost'; render partialResult and offer reconnect
session/bluetooth-off Bluetooth turned off mid-session Arrives as aborted / cause: 'bluetooth-off'; prompt to re-enable, then let the user restart
record/unavailable The console’s workout records could not be read at finalize The result reconciles as source: 'app-estimated' — surface that provenance rather than failing
record/mismatch Reconciled splits did not match the console’s records result.reconciliation.splitsMatched is false — trust totals cautiously; usually benign

Most session failures are read from the snapshot, not caught: phase: 'failed' carries error, and phase: 'aborted' carries a typed cause and a partialResult. Try/catch is for the *Async twins in imperative flows.

firmware/* — firmware updates (FirmwareError)

Section titled “firmware/* — firmware updates (FirmwareError)”
Code What happened Recovery
firmware/manifest-unavailable The firmware manifest could not be fetched (the SDK’s only network touch) Retry via the query’s refetch(); the firmware card keeps its last-known status meanwhile
firmware/download-failed The firmware image download failed Offer retry; check connectivity
firmware/verify-failed The downloaded image failed verification Do not install — retry the download; a corrupt asset
firmware/transfer-failed The OTA transfer to the console failed Keep the console close and awake; retry the update
firmware/device-rejected The console rejected the firmware Wrong image for the model, or the console is busy — do not force it

Firmware cancel() is a status: 'cancelled' state, not one of these codes.

sdk/* — SDK usage (OutsideProviderError and friends)

Section titled “sdk/* — SDK usage (OutsideProviderError and friends)”
Code What happened Recovery
sdk/outside-provider A hook was called outside <ConsoleProvider> A wiring bug — mount the provider above the hook. Not a runtime condition to catch
sdk/disposed The runtime was used after the provider unmounted Do not retain SDK verbs across unmount; re-acquire them from a mounted hook
sdk/duplicate-ble-transport Two BLE transports tried to construct at once A configuration bug — only one provider owns the BLE transport at a time

Inside the SDK, the protocol codec never throws on a malformed frame: decoding returns a Result (ok / error), so one bad packet on the wire is handled as data and dropped, not surfaced as an exception that could crash a live screen. This is internal plumbing — you never see a Result in the public surface — but it is why a garbled frame during a workout is invisible rather than fatal: the frame is discarded, the stream continues, and (if it matters) a session.recording.issues gap records that samples were missed.