Phase D follow-up: retry-policy switch for idempotent reads (#52) #64

Merged
padreug merged 1 commit from phase-d-retry-switch into dev 2026-06-22 09:41:01 +00:00
Owner

The retry half of the 2026-05-26 error-handling agreement (#52, Phase D) — building on the typed LnbitsRpcError already on dev (#61).

What it does

withRetry(fn) retries an operation per the disposition of the error it throws:

  • LnbitsRpcError.retryPolicy — operator_signer_unavailable / rate_limited → backoff, internal_error (and absent/unknown codes) → retry-once;
  • transport timeouts → short backoff;
  • terminal (unauthorized, insufficient_balance, …) and unknown errors → rethrow immediately.

The safety call — why reads only

Applied only to idempotent reads (getWallet/getBalance/listWallets/getPayment/decodePayment + the lnurlw read methods). create_invoice / pay_invoice / lnurlw_create_link are deliberately NOT wrapped — a blind retry would mint a duplicate invoice/link or double-pay. Their errors surface for flow-level handling (the state machine / operator). This is the key reason the switch lives at the per-call read layer rather than as a blanket client retry or a generic XState loop.

Safe to land now

An absent error_code already maps to internal_error (retry-once), so even before lnbits emits codes, idempotent reads just get one transparent retry on a transient blip — no behaviour change otherwise.

Tests

10 new (retry.test.ts): success-first, transient-then-succeed, max-attempts exhaustion, retry-once semantics, terminal-no-retry, timeout-retry, unknown-no-retry, backoff progression, onRetry callback. typecheck 12/12; lnbits 29, full suite green.

Remaining Phase D follow-ups (not in this PR — need the live cash-out path)

  • invoice_already_paid → success-equivalent at the cash-out watch/dispense path (the error layer already flags it terminal-idempotent / isIdempotentSuccess; wiring needs a path that can surface it).
  • Mid-session re-pair detection (flip to "Pairing Required" when a sign fails during a flow; boot-time is done).
  • Surfacing the error code into the state-machine context for transient-vs-terminal UI copy.

Do NOT use the MCP merge endpoint — merge via the Forgejo UI after review.

🤖 Generated with Claude Code

The retry half of the 2026-05-26 error-handling agreement (#52, Phase D) — building on the typed `LnbitsRpcError` already on `dev` (#61). ### What it does `withRetry(fn)` retries an operation per the **disposition** of the error it throws: - `LnbitsRpcError.retryPolicy` — `operator_signer_unavailable` / `rate_limited` → backoff, `internal_error` (and absent/unknown codes) → retry-once; - transport timeouts → short backoff; - **terminal** (`unauthorized`, `insufficient_balance`, …) and **unknown** errors → rethrow immediately. ### The safety call — why reads only Applied **only to idempotent reads** (`getWallet`/`getBalance`/`listWallets`/`getPayment`/`decodePayment` + the `lnurlw` read methods). `create_invoice` / `pay_invoice` / `lnurlw_create_link` are **deliberately NOT wrapped** — a blind retry would mint a duplicate invoice/link or **double-pay**. Their errors surface for flow-level handling (the state machine / operator). This is the key reason the switch lives at the per-call read layer rather than as a blanket client retry or a generic XState loop. ### Safe to land now An absent `error_code` already maps to `internal_error` (retry-once), so even before lnbits emits codes, idempotent reads just get one transparent retry on a transient blip — no behaviour change otherwise. ### Tests 10 new (`retry.test.ts`): success-first, transient-then-succeed, max-attempts exhaustion, retry-once semantics, terminal-no-retry, timeout-retry, unknown-no-retry, backoff progression, `onRetry` callback. typecheck 12/12; lnbits 29, full suite green. ### Remaining Phase D follow-ups (not in this PR — need the live cash-out path) - `invoice_already_paid` → success-equivalent at the cash-out watch/dispense path (the error layer already flags it `terminal-idempotent` / `isIdempotentSuccess`; wiring needs a path that can surface it). - Mid-session re-pair detection (flip to "Pairing Required" when a sign fails *during* a flow; boot-time is done). - Surfacing the error *code* into the state-machine context for transient-vs-terminal UI copy. Do NOT use the MCP merge endpoint — merge via the Forgejo UI after review. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The retry half of the 2026-05-26 error-handling agreement (aiolabs/bitspire#52).
`withRetry` retries an operation per the disposition of the error it throws —
LnbitsRpcError.retryPolicy (operator_signer_unavailable/rate_limited →
backoff, internal_error → retry-once) plus transport timeouts — and rethrows
terminal/unknown errors immediately.

Applied ONLY to idempotent reads (getWallet/getBalance/listWallets/getPayment/
decodePayment + the lnurlw read methods). create_invoice / pay_invoice /
lnurlw_create_link are deliberately NOT wrapped — a blind retry would mint a
duplicate or double-pay; their errors surface for flow-level handling. This is
why the switch lives at the per-call read layer, not as a blanket client retry.

Safe to land before lnbits emits error_code: an absent code already maps to
internal_error (retry-once), so reads get one transparent retry on a transient
blip with no behaviour change otherwise. 10 tests (backoff/terminal/timeout/
unknown/onRetry).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
padreug deleted branch phase-d-retry-switch 2026-06-22 09:41:02 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/bitspire!64
No description provided.