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>
This commit is contained in:
parent
6281c811f6
commit
b0ac34ee01
5 changed files with 236 additions and 1 deletions
79
packages/lnbits/src/__tests__/error-codes.test.ts
Normal file
79
packages/lnbits/src/__tests__/error-codes.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
143
packages/lnbits/src/error-codes.ts
Normal file
143
packages/lnbits/src/error-codes.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue