Merge pull request 'Phase D: typed LNbits error codes + re-pair UX (#52)' (#61) from phase-d-rekey-ux into dev
Reviewed-on: #61
This commit is contained in:
commit
904dae5a17
9 changed files with 332 additions and 24 deletions
|
|
@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
|
||||||
import { useAtmStore } from '@/stores/atm'
|
import { useAtmStore } from '@/stores/atm'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
import { setBranding } from '@/composables/useBranding'
|
import { setBranding } from '@/composables/useBranding'
|
||||||
|
import { classifyInitError } from '@/services/init-error'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Sun, Moon } from 'lucide-vue-next'
|
import { Sun, Moon } from 'lucide-vue-next'
|
||||||
|
|
@ -20,6 +21,46 @@ function formatSats(sats: number): string {
|
||||||
return sats.toLocaleString()
|
return sats.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maintenance-screen copy keyed by the `initError` sentinel. Falls back to a
|
||||||
|
* generic out-of-service message (the raw error text shows under debug only).
|
||||||
|
*/
|
||||||
|
const MAINTENANCE_SCREENS: Record<string, { title: string; message: string }> = {
|
||||||
|
maintenance: {
|
||||||
|
title: 'Under Service',
|
||||||
|
message: 'This machine is currently being serviced. We will be back shortly.',
|
||||||
|
},
|
||||||
|
'awaiting-fees': {
|
||||||
|
title: 'Awaiting Configuration',
|
||||||
|
message:
|
||||||
|
'Awaiting fee configuration from operator. Contact operator to publish initial fee config.',
|
||||||
|
},
|
||||||
|
unpaired: {
|
||||||
|
title: 'Pairing Required',
|
||||||
|
message:
|
||||||
|
'This machine needs to be re-paired by the operator before it can accept transactions.',
|
||||||
|
},
|
||||||
|
'signer-unreachable': {
|
||||||
|
title: 'Signer Unreachable',
|
||||||
|
message: 'Cannot reach the signing service right now. This usually resolves shortly.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const GENERIC_SCREEN = {
|
||||||
|
title: 'ATM Unavailable',
|
||||||
|
message:
|
||||||
|
'This machine is temporarily out of service. Please try again later or use another machine.',
|
||||||
|
}
|
||||||
|
|
||||||
|
const maintenanceScreen = computed(() =>
|
||||||
|
atmStore.initError ? (MAINTENANCE_SCREENS[atmStore.initError] ?? GENERIC_SCREEN) : GENERIC_SCREEN
|
||||||
|
)
|
||||||
|
|
||||||
|
/** True when the screen is a known sentinel (hide the raw debug error line). */
|
||||||
|
const isKnownMaintenanceScreen = computed(
|
||||||
|
() => !!atmStore.initError && atmStore.initError in MAINTENANCE_SCREENS
|
||||||
|
)
|
||||||
|
|
||||||
const formattedBtcPrice = computed(() => {
|
const formattedBtcPrice = computed(() => {
|
||||||
if (atmStore.btcPrice === null) return null
|
if (atmStore.btcPrice === null) return null
|
||||||
const local = `${atmStore.fiatCode}/BTC: ${atmStore.fiatSymbol}${Math.round(atmStore.btcPrice).toLocaleString()}`
|
const local = `${atmStore.fiatCode}/BTC: ${atmStore.fiatSymbol}${Math.round(atmStore.btcPrice).toLocaleString()}`
|
||||||
|
|
@ -96,7 +137,7 @@ onMounted(async () => {
|
||||||
atmStore.startPricePolling()
|
atmStore.startPricePolling()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[App] Initialization failed:', error)
|
console.error('[App] Initialization failed:', error)
|
||||||
atmStore.initError = error instanceof Error ? error.message : 'Initialization failed'
|
atmStore.initError = classifyInitError(error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -141,29 +182,13 @@ function toggleLiveServices() {
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
</svg>
|
</svg>
|
||||||
<h1 class="text-2xl lg:text-[3.5rem] font-bold">
|
<h1 class="text-2xl lg:text-[3.5rem] font-bold">
|
||||||
{{
|
{{ maintenanceScreen.title }}
|
||||||
atmStore.initError === 'maintenance'
|
|
||||||
? 'Under Service'
|
|
||||||
: atmStore.initError === 'awaiting-fees'
|
|
||||||
? 'Awaiting Configuration'
|
|
||||||
: 'ATM Unavailable'
|
|
||||||
}}
|
|
||||||
</h1>
|
</h1>
|
||||||
<p class="max-w-md text-center text-base lg:text-2xl text-muted-foreground">
|
<p class="max-w-md text-center text-base lg:text-2xl text-muted-foreground">
|
||||||
{{
|
{{ maintenanceScreen.message }}
|
||||||
atmStore.initError === 'maintenance'
|
|
||||||
? 'This machine is currently being serviced. We will be back shortly.'
|
|
||||||
: atmStore.initError === 'awaiting-fees'
|
|
||||||
? 'Awaiting fee configuration from operator. Contact operator to publish initial fee config.'
|
|
||||||
: 'This machine is temporarily out of service. Please try again later or use another machine.'
|
|
||||||
}}
|
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
v-if="
|
v-if="atmStore.debugMode && !isKnownMaintenanceScreen"
|
||||||
atmStore.debugMode &&
|
|
||||||
atmStore.initError !== 'maintenance' &&
|
|
||||||
atmStore.initError !== 'awaiting-fees'
|
|
||||||
"
|
|
||||||
class="max-w-lg text-center font-mono text-sm text-destructive"
|
class="max-w-lg text-center font-mono text-sm text-destructive"
|
||||||
>
|
>
|
||||||
{{ atmStore.initError }}
|
{{ atmStore.initError }}
|
||||||
|
|
|
||||||
30
apps/machine/src/services/__tests__/init-error.test.ts
Normal file
30
apps/machine/src/services/__tests__/init-error.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { BunkerRejectedError, BunkerTimeoutError } from '@bitSpire/nostr-client'
|
||||||
|
import { classifyInitError } from '../init-error.js'
|
||||||
|
|
||||||
|
describe('classifyInitError', () => {
|
||||||
|
it('maps a bunker rejection (revoke / TTL / off-policy) to "unpaired"', () => {
|
||||||
|
expect(classifyInitError(new BunkerRejectedError('revoked'))).toBe('unpaired')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps a bunker timeout to "signer-unreachable"', () => {
|
||||||
|
expect(classifyInitError(new BunkerTimeoutError('no response'))).toBe('signer-unreachable')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('classifies by error name across bundle boundaries (no instanceof)', () => {
|
||||||
|
// A structurally-equivalent error from a different module copy still maps.
|
||||||
|
const lookalike = Object.assign(new Error('x'), { name: 'BunkerRejectedError' })
|
||||||
|
expect(classifyInitError(lookalike)).toBe('unpaired')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces a generic error message unchanged', () => {
|
||||||
|
expect(classifyInitError(new Error('relay down'))).toBe('relay down')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the fallback for non-Error throws', () => {
|
||||||
|
expect(classifyInitError('boom', 'Lightning initialization failed')).toBe(
|
||||||
|
'Lightning initialization failed'
|
||||||
|
)
|
||||||
|
expect(classifyInitError(undefined)).toBe('Initialization failed')
|
||||||
|
})
|
||||||
|
})
|
||||||
17
apps/machine/src/services/init-error.ts
Normal file
17
apps/machine/src/services/init-error.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
/**
|
||||||
|
* Classify an initialization failure into a maintenance-screen sentinel
|
||||||
|
* (see App.vue's MAINTENANCE_SCREENS).
|
||||||
|
*
|
||||||
|
* Bunker failures (aiolabs/bitspire#52) get dedicated screens:
|
||||||
|
* - `BunkerRejectedError` (revoked / TTL-expired / off-policy binding) →
|
||||||
|
* `unpaired` — the operator must re-pair the machine.
|
||||||
|
* - `BunkerTimeoutError` (signer/relay unreachable) → `signer-unreachable`,
|
||||||
|
* a transient condition.
|
||||||
|
* Everything else surfaces its raw message (or the caller's fallback).
|
||||||
|
*/
|
||||||
|
export function classifyInitError(error: unknown, fallback = 'Initialization failed'): string {
|
||||||
|
const name = (error as { name?: string } | null)?.name
|
||||||
|
if (name === 'BunkerRejectedError') return 'unpaired'
|
||||||
|
if (name === 'BunkerTimeoutError') return 'signer-unreachable'
|
||||||
|
return error instanceof Error ? error.message : fallback
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
type ATMMachine,
|
type ATMMachine,
|
||||||
} from '@bitSpire/state-machine'
|
} from '@bitSpire/state-machine'
|
||||||
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
||||||
|
import { classifyInitError } from '@/services/init-error'
|
||||||
import {
|
import {
|
||||||
startOperatorConfigService,
|
startOperatorConfigService,
|
||||||
type OperatorConfigService,
|
type OperatorConfigService,
|
||||||
|
|
@ -706,7 +707,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
useLiveServices.value = false
|
useLiveServices.value = false
|
||||||
initialize(mockServices)
|
initialize(mockServices)
|
||||||
} else {
|
} else {
|
||||||
initError.value = error instanceof Error ? error.message : 'Lightning initialization failed'
|
initError.value = classifyInitError(error, 'Lightning initialization failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1019,7 +1020,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
useLiveServices.value = false
|
useLiveServices.value = false
|
||||||
initialize(mockServices)
|
initialize(mockServices)
|
||||||
} else {
|
} else {
|
||||||
initError.value = error instanceof Error ? error.message : 'HAL initialization failed'
|
initError.value = classifyInitError(error, 'HAL initialization failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1330,7 +1331,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
initialize(mockServices)
|
initialize(mockServices)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
initError.value = error instanceof Error ? error.message : 'Hardware initialization failed'
|
initError.value = classifyInitError(error, 'Hardware initialization failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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,
|
type Event as NostrEvent,
|
||||||
} from '@bitSpire/nostr-client'
|
} from '@bitSpire/nostr-client'
|
||||||
import { verifyEvent } from 'nostr-tools'
|
import { verifyEvent } from 'nostr-tools'
|
||||||
|
import { LnbitsRpcError } from './error-codes.js'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
LnbitsConfig,
|
LnbitsConfig,
|
||||||
|
|
@ -478,7 +479,7 @@ export class LnbitsClient {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
this.pending.delete(requestId)
|
this.pending.delete(requestId)
|
||||||
if (response.status === 'ERROR') {
|
if (response.status === 'ERROR') {
|
||||||
reject(new Error(response.error ?? `${rpcName}: server returned ERROR`))
|
reject(LnbitsRpcError.fromResponse(rpcName, response))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resolve(response.data as T)
|
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 { LnbitsClient } from './client.js'
|
||||||
|
export {
|
||||||
|
LnbitsErrorCode,
|
||||||
|
LnbitsRpcError,
|
||||||
|
parseErrorCode,
|
||||||
|
retryPolicyFor,
|
||||||
|
} from './error-codes.js'
|
||||||
|
export type { RetryPolicy } from './error-codes.js'
|
||||||
export type {
|
export type {
|
||||||
LnbitsConfig,
|
LnbitsConfig,
|
||||||
LnbitsRpcRequest,
|
LnbitsRpcRequest,
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,12 @@ export interface LnbitsRpcResponse<T = unknown> {
|
||||||
/** Non-null on subscription push events. Null on regular acks. */
|
/** Non-null on subscription push events. Null on regular acks. */
|
||||||
subscription_id?: string | null
|
subscription_id?: string | null
|
||||||
data?: T
|
data?: T
|
||||||
|
/** Human-readable error detail (ERROR status only). */
|
||||||
error?: string
|
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