Error Handling Guide

Error Class Hierarchy

Diagram 0

Import

import { OpenloopError, ApduError, TransportError } from '@openloop/sdk-core'

ApduError

Thrown when the device returns an error status word — anything other than 0x9000.

class ApduError extends OpenloopError {
  readonly statusWord: number  // e.g. 0x6985
}

APDU Status Word Table

Status word Constant name Description Common cause
0x9000 — Success —
0x6985 Conditions not satisfied Rejected, or the device is busy The user pressed the reject button, or a request arrived while another confirmation was on screen (answered immediately on device firmware v1.1.5 and later)
0x6a80 Invalid data Invalid data A malformed BIP44 path or transaction
0x6a82 App not found App not found The matching app is not open on the device
0x6d00 Instruction not supported Instruction not supported The device firmware version is too old
0x6e00 CLA not supported CLA not supported Invalid class byte
0x6f00 Internal error Internal error An unexpected error inside the device
0x61XX More data More data available Intermediate response while fetching a PSBT (not an error)

Example

import { ApduError } from '@openloop/sdk-core'

try {
  const sig = await eth.signPersonalMessage(DEFAULT_ETH_PATH, 'Hello')
} catch (err) {
  if (err instanceof ApduError) {
    if (err.statusWord === 0x6985) {
      console.log('Rejected on the device, or the device is busy. Check the screen and try again')
    } else {
      console.log(`APDU error: 0x${err.statusWord.toString(16)}`)
    }
  }
}

TransportError

Thrown when a communication error occurs in the transport layer — USB, BLE, WebSocket, and so on.

class TransportError extends OpenloopError {
  readonly code: TransportErrorCode   // added in v0.6.0
  // message carries English detail — for logs, not for display
}

Branch on code (added in v0.6.0)

message is free-form English, so do not branch on the string. Branch on code.

code Meaning What to do
not-supported This runtime cannot talk to the device at all (no WebHID / Web Bluetooth) Point the user at a supported browser
not-found No device matched, or the chooser was dismissed Ask the user to pick again
not-connected There is no open connection (never opened, or already closed) Call open() before using it
timeout The device did not answer in time Ask the user to check the device — it may be waiting for approval
disconnected The link dropped while in use Try reconnecting
io Anything else (framing, write failure, unexpected payload) Log message

code defaults to io. Handle unknown values in a default branch so new codes cannot break you.

Errors the device returned are ApduError

In v0.6.0 the BLE, Safari and USB transports stopped discarding the status word and now throw ApduError. Previously these surfaced as TransportError, so callers could not branch on statusWord. Handle device-side failures (rejection, conditions not satisfied, and so on) in the ApduError branch.


Error Handling Patterns

The Basic Pattern

import { OpenloopError, ApduError, TransportError } from '@openloop/sdk-core'

try {
  const { address } = await eth.getAddress(DEFAULT_ETH_PATH)
} catch (err) {
  if (err instanceof ApduError) {
    // Error response from the device
    switch (err.statusWord) {
      case 0x6985:
        showMessage('Rejected on the device, or the device is busy. Check the screen and try again')
        break
      case 0x6a80:
        showMessage('Invalid data')
        break
      default:
        showMessage(`Device error: ${err.message}`)
    }
  } else if (err instanceof TransportError) {
    // Communication error — branch on code, not on message
    switch (err.code) {
      case 'not-supported':
        showMessage('This browser is not supported.')
        break
      case 'not-found':
        showMessage('No device was found.')
        break
      case 'timeout':
        showMessage('The device is not responding. Please check its screen.')
        break
      case 'disconnected':
      case 'not-connected':
        showMessage('Lost the connection to the device. Please reconnect.')
        break
      default:
        showMessage(`Communication error: ${err.message}`)
    }
  } else if (err instanceof OpenloopError) {
    // Any other SDK error
    showMessage(`Error: ${err.message}`)
  } else {
    // Unexpected error
    throw err
  }
}

Retry Pattern With Reconnect

async function withRetry<T>(
  fn: () => Promise<T>,
  reconnect: () => Promise<void>,
  maxRetries: number = 1
): Promise<T> {
  for (let i = 0; i <= maxRetries; i++) {
    try {
      return await fn()
    } catch (err) {
      if (err instanceof TransportError && i < maxRetries) {
        await reconnect()
        continue
      }
      throw err
    }
  }
  throw new Error('Unreachable')
}

// Usage example
const { address } = await withRetry(
  () => eth.getAddress(DEFAULT_ETH_PATH),
  async () => {
    transport = await WebHidTransport.reconnect()
    eth = new EthereumApp(transport!)
  }
)

Detecting a Rejection or a Busy Device

function isRejectedOrBusy(err: unknown): boolean {
  return err instanceof ApduError && err.statusWord === 0x6985
}

try {
  const sig = await eth.signTransaction(DEFAULT_ETH_PATH, rawTx)
} catch (err) {
  if (isRejectedOrBusy(err)) {
    // Rejected or busy — check the screen and try again
    showMessage('Rejected on the device, or the device is busy. Check the screen and try again')
    return
  }
  // Show every other error
  showError(err)
}

Next Steps