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.
One class tree, namespaced codes
Section titled “One class tree, namespaced codes”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', withcauseon the disconnected state. - A failed workout is
session.phase === 'failed', carrying a typedSessionErrorinsession.error— and an aborted one issession.phase === 'aborted'with a typedcause.
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.
Channel 2 — commands: paired verbs
Section titled “Channel 2 — commands: paired verbs”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’serror/errorsfield, the session snapshot (startError, phase'failed'), or DEV-warning +onErrorfor plain action hooks. This is what makesonPress={() => connect(item)}andonPress={pause}safe: a fire-and-forget handler whose failure is already rendered from state can never float an unhandled rejection. - The
*Asynctwin (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.
Channel 3 — the provider onError sink
Section titled “Channel 3 — the provider onError sink”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).
Cancellation is not an error
Section titled “Cancellation is not an error”Cancelling something the user asked to cancel is a normal outcome, not a failure:
- Firmware
cancel()produces a distinctstatus: 'cancelled'variant witherror: null— a state, not a thrown error. - An aborted
connect()rejectsconnectAsyncwith'connect/cancelled'only because a promise must settle. It is documented as a non-failure and excluded fromonError. 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 assession.phase === 'aborted'withcause: 'console-stopped'.
The code table, with recovery guidance
Section titled “The code table, with recovery guidance”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 |
Result at decode boundaries
Section titled “Result at decode boundaries”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.