Skip to content

Errors

Outcome

Handle a KeiError by its stable code and preserve its actionable message. The proof exercises three real paths: an injected offline node read, a stale accepted market offer, and a payment memo the wire format cannot carry.

Run the error proof

sh
bun install --frozen-lockfile
bun run docs/playgrounds/errors.ts
# {"kind":"error-categories","actions":{"nodeUnreachableRead":"retry","nodeUnreachableWrite":"refresh","offerTaken":"refresh","noMemoYet":"permanent"},"codes":["node-unreachable","offer-taken","no-memo-yet"]}

The offline node uses an injected fetch that throws locally. The playground makes no network request.

ts
import { strict as assert } from 'node:assert'

import { HttpNode, Kei, KeiError, randomSeed } from 'kei-transaction'

type Operation = 'read' | 'write'
type Recovery = 'retry' | 'refresh' | 'permanent'

const TRANSPORT_CODES = new Set(['node-unreachable', 'node-timeout'])
const STALE_STATE_CODES = new Set([
  'already-claimed',
  'root-closed',
  'offer-taken',
  'offer-cancelled',
  'offer-changed',
])

function recoveryFor(error: KeiError, operation: Operation): Recovery {
  if (TRANSPORT_CODES.has(error.code)) {
    // A read is safe to repeat. A signed write may have landed before the reply
    // was lost, so it must refresh/reconcile state before any resubmission.
    return operation === 'read' ? 'retry' : 'refresh'
  }
  if (STALE_STATE_CODES.has(error.code)) return 'refresh'
  return 'permanent'
}

async function refused(work: () => Promise<unknown>): Promise<KeiError> {
  let caught: unknown
  try {
    await work()
  } catch (error) {
    caught = error
  }
  assert.ok(caught instanceof KeiError)
  return caught
}

const node = await Kei.mock()
const game = await Kei.server({ seed: 'C'.repeat(64), node })
await game.faucet(20_000)

const seller = await Kei.start({ node, seed: randomSeed(), autoCancelExpired: false })
const buyer = await Kei.start({ node, seed: randomSeed(), autoCancelExpired: false })
await Promise.all([game.send(seller.address, 100), game.send(buyer.address, 100)])
await Promise.all([seller.sync(), buyer.sync()])

const item = await game.items.create({ name: 'Error Proof Sword' })
await game.items.mint(item.id, seller.address)
await seller.sync()

const offer = await seller.market.sell({ asset: item, price: 5 })
await buyer.market.accept(offer)
const staleOffer = await refused(() => buyer.market.accept(offer))
assert.equal(staleOffer.code, 'offer-taken')
assert.equal(recoveryFor(staleOffer, 'write'), 'refresh')

const noMemo = await refused(() => buyer.pay({
  to: game.address,
  amount: 0.05,
  memo: 'order-123',
}))
assert.equal(noMemo.code, 'no-memo-yet')
assert.equal(recoveryFor(noMemo, 'write'), 'permanent')

const offline = new HttpNode({
  url: 'https://offline.invalid/rpc',
  // A stub that only ever throws still has to satisfy `typeof fetch`, which
  // carries `preconnect` on this runtime. The cast is the stub saying so.
  fetch: (async () => { throw new Error('offline by construction') }) as unknown as typeof globalThis.fetch,
})
const unavailable = await refused(() => offline.accountInfo(buyer.address))
assert.equal(unavailable.code, 'node-unreachable')
assert.equal(recoveryFor(unavailable, 'read'), 'retry')
assert.equal(recoveryFor(unavailable, 'write'), 'refresh')

game.close()
seller.close()
buyer.close()

console.log(JSON.stringify({
  kind: 'error-categories',
  actions: {
    nodeUnreachableRead: recoveryFor(unavailable, 'read'),
    nodeUnreachableWrite: recoveryFor(unavailable, 'write'),
    offerTaken: recoveryFor(staleOffer, 'write'),
    noMemoYet: recoveryFor(noMemo, 'write'),
  },
  codes: [unavailable.code, staleOffer.code, noMemo.code],
}))

Authority and trust boundary

The SDK owns error codes and safe human messages. Application code owns the operation context: whether a failed call was a read, an unsigned preparation, or a signed write that may already have landed. A UI cache never overrules a fresh ledger read.

Do not parse message text to choose control flow. Show it when safe and use the code for branching — but read where the code comes from first. Not every code you see under Kei.mock() exists on the public node.

Recovery categories

CategoryExamplesAction
Retrynode-unreachable or node-timeout during a readRetry with bounded backoff.
Refreshoffer-taken, offer-cancelled, offer-changed, and every node-errorRe-read authoritative state and update the UI/work queue.
Permanent refusalno-memo-yet, bad address/amount, immutable policy, insufficient balanceChange the request or obtain a new user decision; repeating it unchanged cannot help.

A transport error after a signed write belongs in refresh/reconciliation, not automatic retry. The node may have accepted the block before its reply was lost.

Where the code comes from decides whether it is stable

This is the distinction that decides whether a branch you write will ever run, and it is measured rather than asserted — see the public testnet proof.

OriginExamplesStable?
Client-side, before a block reaches the wireno-memo-yet, insufficient-kei, not-in-commit, bad address or amountYes. Identical under Kei.mock() and against the public node.
Client-side, from a fresh read of ledger stateoffer-taken, offer-cancelled, offer-changedYes, for the same reason: @keicoin/market derives them from what it read.
Transportnode-unreachable, node-timeoutYes.
A ledger write the node refusedalready-claimed, root-closed, bad-proof, insufficient-balance, transfer-not-permittedNo. These are the mock ledger's codes. The public node refuses the write as node-error and puts the reason in the message.

The bottom row is not a licence to parse messages for control flow. On a node-error, branch on a fresh read of authoritative statecommitInfo(root), the account chain, the holding, the offer — not on the sentence. Show the sentence to the operator; decide from the ledger.

Error state transitions

  1. Catch unknown and narrow with error instanceof KeiError.
  2. Read error.code and the operation context.
  3. Retry read-only transport failures with a bound, refresh stale or ambiguous state, and stop deterministic refusals.
  4. Reconcile a signed write by hash/account state before any resubmission.
  5. Preserve the SDK message for the player or operator when it contains no application secret.

Preserve the message

ts
try {
  await kei.send(recipient, 1.2)
} catch (error) {
  if (error instanceof KeiError) {
    showTransactionError(error.message)
    reportCode(error.code)
  }
}

Do not replace an actionable SDK error with “Something went wrong.” Do not put seeds, authorization headers, or complete private request bodies beside it in logs.

Failure cases

  • Unknown exceptions are not safe to retry automatically. Stop, retain sanitized context, and investigate.
  • A refresh loop needs a bound; stale state that never converges is an operational failure.
  • A retry budget is not permission to replay signed writes.
  • Application-specific fulfillment must have its own durable idempotency key; the SDK cannot make two database grants one grant.

Package types are authoritative

This documentation demonstrates the current public surface. The installed package's TypeScript declarations and KeiError.code are authoritative for exact arguments and codes.

What Kei.mock() proves

The market and memo refusals execute against the in-process ledger. The retry case executes HttpNode with a local throwing transport to prove the stable node-unreachable code without I/O. The example proves classification logic, not network recovery, database rollback behavior, or that every future code belongs in one of the listed sets. Unknown codes fail closed.

It also cannot prove that the codes it classifies are the codes the public node sends, because it never speaks to one. For that, run the public testnet proof, which asserts the live codes directly.

The package is the source of truth for the API.