Phase D: typed LNbits error codes + re-pair UX (#52) #61

Merged
padreug merged 2 commits from phase-d-rekey-ux into dev 2026-06-21 10:43:12 +00:00
5 changed files with 236 additions and 1 deletions
Showing only changes of commit b0ac34ee01 - Show all commits

feat(lnbits): typed nostr-transport error codes + retry policy

Implements the error-handling layer agreed in the 2026-05-26 cross-session
handshake (aiolabs/bitspire#52). LnbitsClient now rejects ERROR responses
with a typed LnbitsRpcError carrying the machine-readable code + its retry
disposition, so callers (and the state machine, Phase D.3) branch on
disposition rather than string-matching the human-readable message.

- error-codes.ts: LnbitsErrorCode (14 codes, signer/transport/app classes)
  mirroring the lnbits canonical enum; retryPolicyFor() classifier;
  LnbitsRpcError.fromResponse().
- error_code is optional-additive on the wire: an absent or unknown code
  maps to internal_error (retry-once), so this is safe to land before lnbits
  emits codes — no string-matching, no special parser paths.
- invoice_already_paid is flagged terminal-idempotent (isIdempotentSuccess)
  for the cash-out resume-after-reboot case.

Part of Phase D, aiolabs/bitspire#52.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Padreug 2026-06-19 23:29:50 +02:00 • committed by padreug

View file

@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest'
import {
LnbitsErrorCode,
LnbitsRpcError,
parseErrorCode,
retryPolicyFor,
} from '../error-codes.js'
describe('parseErrorCode', () => {
it('recognizes every canonical code', () => {
for (const code of Object.values(LnbitsErrorCode)) {
expect(parseErrorCode(code)).toBe(code)
}
})
it('returns null for unknown / absent codes', () => {
expect(parseErrorCode('made_up_code')).toBeNull()
expect(parseErrorCode(undefined)).toBeNull()
expect(parseErrorCode(null)).toBeNull()
expect(parseErrorCode('')).toBeNull()
})
})
describe('retryPolicyFor', () => {
it('classifies the signer + transport + app codes as agreed', () => {
expect(retryPolicyFor(LnbitsErrorCode.OperatorSignerUnavailable)).toBe('retry-backoff')
expect(retryPolicyFor(LnbitsErrorCode.OperatorSignerRejected)).toBe('terminal')
expect(retryPolicyFor(LnbitsErrorCode.RateLimited)).toBe('retry-long-backoff')
expect(retryPolicyFor(LnbitsErrorCode.InternalError)).toBe('retry-once')
expect(retryPolicyFor(LnbitsErrorCode.InvoiceAlreadyPaid)).toBe('terminal-idempotent')
expect(retryPolicyFor(LnbitsErrorCode.InsufficientBalance)).toBe('terminal')
})
it('has a policy for every code (exhaustive map)', () => {
for (const code of Object.values(LnbitsErrorCode)) {
expect(retryPolicyFor(code)).toBeTruthy()
}
})
})
describe('LnbitsRpcError.fromResponse', () => {
it('maps a known error_code through', () => {
const err = LnbitsRpcError.fromResponse('pay_invoice', {
request_id: 'pay-1',
error_code: 'insufficient_balance',
error: 'not enough sats',
})
expect(err).toBeInstanceOf(LnbitsRpcError)
expect(err.code).toBe(LnbitsErrorCode.InsufficientBalance)
expect(err.rpcName).toBe('pay_invoice')
expect(err.requestId).toBe('pay-1')
expect(err.message).toBe('not enough sats')
expect(err.retryPolicy).toBe('terminal')
expect(err.isRetryable).toBe(false)
})
it('treats an ABSENT error_code as internal_error (retry-once)', () => {
const err = LnbitsRpcError.fromResponse('get_wallet', { request_id: 'w-1' })
expect(err.code).toBe(LnbitsErrorCode.InternalError)
expect(err.retryPolicy).toBe('retry-once')
expect(err.isRetryable).toBe(true)
})
it('treats an UNKNOWN error_code as internal_error', () => {
const err = LnbitsRpcError.fromResponse('get_wallet', {
request_id: 'w-2',
error_code: 'brand_new_code_we_dont_know',
})
expect(err.code).toBe(LnbitsErrorCode.InternalError)
})
it('flags invoice_already_paid as idempotent-success', () => {
const err = LnbitsRpcError.fromResponse('pay_invoice', {
request_id: 'p-1',
error_code: 'invoice_already_paid',
})
expect(err.isIdempotentSuccess).toBe(true)
})
})

View file

@ -26,6 +26,7 @@ import {
type Event as NostrEvent,
} from '@bitSpire/nostr-client'
import { verifyEvent } from 'nostr-tools'
import { LnbitsRpcError } from './error-codes.js'
import type {
LnbitsConfig,
@ -478,7 +479,7 @@ export class LnbitsClient {
clearTimeout(timer)
this.pending.delete(requestId)
if (response.status === 'ERROR') {
reject(new Error(response.error ?? `${rpcName}: server returned ERROR`))
reject(LnbitsRpcError.fromResponse(rpcName, response))
return
}
resolve(response.data as T)

View file

@ -0,0 +1,143 @@
/**
* LNbits nostr-transport error taxonomy.
*
* Machine-readable discriminators for kind-21000 ERROR responses. Mirror of
* the lnbits canonical enum (`core/services/nostr_transport/error_codes.py`)
* and the vocabulary table in `docs/devs/nostr-transport.md`. Drift detection
* = diff this enum against that table. Agreed in the 2026-05-26 cross-session
* handshake on aiolabs/bitspire#52.
*
* Wire shape (additive to the existing envelope):
* { "status": "ERROR", "request_id": "...", "error_code": "...", "error": "..." }
*
* `error_code` is optional-additive for one lnbits release, then required.
* An ABSENT `error_code` is treated as `internal_error` (retry-once) — we do
* not string-match the human-readable `error`. So un-migrated handlers get a
* safe retry-then-surface default with no special parser paths.
*/
export enum LnbitsErrorCode {
// signer class — the operator's signer (bunker) on the LNbits side
OperatorSignerUnavailable = 'operator_signer_unavailable',
OperatorSignerRejected = 'operator_signer_rejected',
OperatorSignerUnconfigured = 'operator_signer_unconfigured',
// transport class
Unauthorized = 'unauthorized',
RateLimited = 'rate_limited',
UnknownMethod = 'unknown_method',
InvalidParams = 'invalid_params',
InternalError = 'internal_error',
// app class
WalletNotFound = 'wallet_not_found',
InsufficientBalance = 'insufficient_balance',
InvoiceAlreadyPaid = 'invoice_already_paid',
InvoiceExpired = 'invoice_expired',
PaymentFailed = 'payment_failed',
AccountNotFound = 'account_not_found',
}
/**
* Retry disposition for an error code:
* - `retry-backoff` — transient; retry with short exponential backoff.
* - `retry-long-backoff` — rate-limited; retry with a longer backoff.
* - `retry-once` — retry exactly once, then surface (the `internal_error` default).
* - `terminal` — do not retry; surface to the user.
* - `terminal-idempotent` — terminal, but the operation already took effect
* (e.g. `invoice_already_paid` — a cash-out watcher treats it as settled).
*/
export type RetryPolicy =
| 'retry-backoff'
| 'retry-long-backoff'
| 'retry-once'
| 'terminal'
| 'terminal-idempotent'
const RETRY_POLICIES: Record<LnbitsErrorCode, RetryPolicy> = {
[LnbitsErrorCode.OperatorSignerUnavailable]: 'retry-backoff',
[LnbitsErrorCode.OperatorSignerRejected]: 'terminal',
[LnbitsErrorCode.OperatorSignerUnconfigured]: 'terminal',
[LnbitsErrorCode.Unauthorized]: 'terminal',
[LnbitsErrorCode.RateLimited]: 'retry-long-backoff',
[LnbitsErrorCode.UnknownMethod]: 'terminal',
[LnbitsErrorCode.InvalidParams]: 'terminal',
[LnbitsErrorCode.InternalError]: 'retry-once',
[LnbitsErrorCode.WalletNotFound]: 'terminal',
[LnbitsErrorCode.InsufficientBalance]: 'terminal',
[LnbitsErrorCode.InvoiceAlreadyPaid]: 'terminal-idempotent',
[LnbitsErrorCode.InvoiceExpired]: 'terminal',
// payment_failed is terminal-with-detail: the sub-reason rides in `error`.
[LnbitsErrorCode.PaymentFailed]: 'terminal',
[LnbitsErrorCode.AccountNotFound]: 'terminal',
}
const RETRYABLE: ReadonlySet<RetryPolicy> = new Set<RetryPolicy>([
'retry-backoff',
'retry-long-backoff',
'retry-once',
])
/** Parse a wire string into a known code, or null if unrecognized. */
export function parseErrorCode(raw: string | undefined | null): LnbitsErrorCode | null {
if (!raw) return null
return (Object.values(LnbitsErrorCode) as string[]).includes(raw)
? (raw as LnbitsErrorCode)
: null
}
/** Retry disposition for a code. */
export function retryPolicyFor(code: LnbitsErrorCode): RetryPolicy {
return RETRY_POLICIES[code]
}
/**
* Typed error thrown by `LnbitsClient` on an ERROR response. Carries the
* machine-readable `code` + its `retryPolicy` so callers (and the state
* machine) branch on disposition rather than string-matching `message`.
*/
export class LnbitsRpcError extends Error {
readonly code: LnbitsErrorCode
readonly rpcName: string
readonly requestId: string
readonly retryPolicy: RetryPolicy
constructor(args: {
code: LnbitsErrorCode
rpcName: string
requestId: string
message?: string
}) {
super(args.message ?? `${args.rpcName}: ${args.code}`)
this.name = 'LnbitsRpcError'
this.code = args.code
this.rpcName = args.rpcName
this.requestId = args.requestId
this.retryPolicy = retryPolicyFor(args.code)
}
/**
* Build from a wire ERROR response. An absent/unknown `error_code` maps to
* `internal_error` (retry-once) per the deprecation-window contract.
*/
static fromResponse(
rpcName: string,
response: { request_id: string; error_code?: string | null; error?: string }
): LnbitsRpcError {
const code = parseErrorCode(response.error_code) ?? LnbitsErrorCode.InternalError
return new LnbitsRpcError({
code,
rpcName,
requestId: response.request_id,
message: response.error ?? `${rpcName}: server returned ERROR (${code})`,
})
}
/** True when the disposition permits a retry (any backoff/once policy). */
get isRetryable(): boolean {
return RETRYABLE.has(this.retryPolicy)
}
/** True when the operation already took effect despite the error. */
get isIdempotentSuccess(): boolean {
return this.retryPolicy === 'terminal-idempotent'
}
}

View file

@ -49,6 +49,13 @@
*/
export { LnbitsClient } from './client.js'
export {
LnbitsErrorCode,
LnbitsRpcError,
parseErrorCode,
retryPolicyFor,
} from './error-codes.js'
export type { RetryPolicy } from './error-codes.js'
export type {
LnbitsConfig,
LnbitsRpcRequest,

View file

@ -39,7 +39,12 @@ export interface LnbitsRpcResponse<T = unknown> {
/** Non-null on subscription push events. Null on regular acks. */
subscription_id?: string | null
data?: T
/** Human-readable error detail (ERROR status only). */
error?: string
/** Machine-readable error discriminator (ERROR status). Optional-additive
* for one lnbits release, then required; absent → internal_error. See
* error-codes.ts (aiolabs/bitspire#52). */
error_code?: string
}
// ============================================================================