fix(ibex): surface typed INSUFFICIENT_BALANCE on payment failure - #476
Merged
Conversation
When an IBEX payment failed for lack of funds (400 with body
{"error":"insufficient balance. Current Balance: ..."}), the GraphQL API
returned only a generic error. Two stacked defects:
1. errors.ts classified by e.message.includes("insufficient balance"), but
ibex-client@3.2.0's ApiError message is only the wrapped FetchError stack
("FetchError: Bad Request...") — the JSON body is discarded at wrap time
(lnflash/ibex-client#6), so the InsufficientIbexBalance branch never fired
on the SDK pay path. This also silently broke the fygaro credit-topup
float-exhaustion detection, which matches on InsufficientIbexBalance.
2. lnInvoicePaymentSend collapsed every IbexError subclass into a hardcoded
"An unexpected error occurred." message, bypassing the error map.
Fix, working with the pinned ibex-client@3.2.0:
- payInvoice now calls the generated SDK through Ibex.authentication.withAuth
directly, so the raw FetchError — which carries the parsed IBEX body on
`.data` — reaches the new httpErrorHandler before the ApiError wrapper can
discard it. httpErrorHandler classifies insufficient balance / payment
already prepared from the body and wraps everything in the existing
IbexError hierarchy (httpCode preserved).
- errorHandler now also reads structured detail (duck-typed `ibexMessage` /
`.data`) so other call sites pick up classification automatically once an
ibex-client release with lnflash/ibex-client#6's fix is adopted; the
existing message-text fallback is preserved.
- InsufficientIbexBalance now carries a clean client-facing message (the IBEX
detail when available, "insufficient balance" otherwise) — error-map
forwards `message` verbatim, which previously would have exposed a stack
trace.
- lnInvoicePaymentSend branches on InsufficientIbexBalance and returns the
mapped INSUFFICIENT_BALANCE error; all other IBEX errors keep the existing
generic message. error-map already mapped InsufficientIbexBalance →
INSUFFICIENT_BALANCE; no change needed there.
Tests: services/ibex/errors.spec.ts (detail extraction, errorHandler old and
new shapes, httpErrorHandler JSON/non-JSON/other-400/network cases),
services/ibex/pay-invoice.spec.ts (payInvoice wiring through withAuth),
graphql/error-map.spec.ts (INSUFFICIENT_BALANCE mapping, generic IBEX_ERROR
unchanged), graphql/ln-invoice-payment-send.spec.ts (resolver branches).
Fixes #93
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
…hing Review fixes for PR #476: - httpErrorHandler no longer discards the extracted IBEX body detail for unclassified errors: the detail is prepended to the wrapped ApiError's message so an unrecognized IBEX 400 logs what IBEX actually said instead of only "FetchError: Bad Request" plus a stack. - Classification needles now live in a single shared classifyIbexErrorText helper used by both errorHandler and httpErrorHandler, and matching is case-insensitive so a vendor rewording (e.g. "Insufficient Balance") cannot silently revert typed errors to the generic path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
…y detail on errorHandler fall-through Review fixes for PR #476: - InsufficientIbexBalance no longer ships IBEX's internal account UUID ("... account: <uuid>") to end users — the client-facing message strips the trailing account tail; the unstripped vendor text is preserved on a new `detail` property for logs/spans. - errorHandler's unclassified ApiError fall-through now mirrors httpErrorHandler's body-detail carry: when a body-carrying ApiError (future ibex-client shape, lnflash/ibex-client#12) matches no needle, the extracted detail is prepended to a fresh generic IbexError's message instead of being dropped, without mutating the caller's error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
bobodread876
approved these changes
Aug 12, 2026
islandbitcoin
added a commit
that referenced
this pull request
Aug 12, 2026
…477) * chore(deps): bump ibex-client to ^3.3.0 for structured error details ibex-client 3.3.0 (lnflash/ibex-client#12) populates httpCode, ibexResponse, and ibexMessage on ApiError at construction, so every IBEX call site now gets the response detail #476 could only extract at the payInvoice raw-fetch seam under pinned 3.2.0. No code changes needed: errors.ts already duck-types ibexMessage. New integration tests construct the real 3.3.0 ApiError (no simulated fields) and pin extraction, insufficient-balance classification with account-id stripping, and the unclassified fall-through detail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH * fix(ibex): dedupe error detail carry, refresh stale 3.2.0 comments Review fixes for #477: - errorHandler/httpErrorHandler no longer prepend the IBEX body detail when the message already contains it — ibex-client@3.3.0's ApiError embeds "IBEX response (<code>): <detail>" itself, so the unclassified carries were logging every unrecognized IBEX error's detail twice (and the duplicate reached Discord alert embeds via #475). Tests extended to pin the detail appearing exactly once (verified failing against the pre-fix code). - Rewrote the payInvoice raw-fetch seam comment: the 3.2.0 body-discarding rationale no longer holds on 3.3.0; the seam is belt-and-braces only, follow-up to collapse it filed as #478. - Rewrote httpErrorHandler's docblock and errorHandler's classification comment to describe 3.3.0 behavior (ApiError carries the body; httpErrorHandler stays as defense-in-depth for the raw-fetch seam). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH * fix(ibex): derive dedupe detail from the capped ApiError extraction httpErrorHandler extracted the detail from the raw FetchError's .data (uncapped) but compared it against the ApiError-wrapped message, which embeds only ibex-client's MAX_IBEX_MESSAGE_LENGTH-truncated copy. For any body over the cap — the Cloudflare-HTML-error-page outage the cap exists for — the includes() guard always missed, prepending the full multi-KB body onto every failing call's message with the first 500 chars duplicated (truncated + full), defeating the upstream cap in logs and Discord alert embeds. Construct the ApiError first and derive the detail from it: both sides of the dedupe comparison now use the same bounded string. When the raw error is already an IbexClientError, wrapped === raw and the extraction is unchanged. The uncapped body remains available on ibexResponse. Adds a regression test feeding a body 4x over MAX_IBEX_MESSAGE_LENGTH through httpErrorHandler, asserting the message stays bounded and the truncated detail appears exactly once (fails against the previous implementation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH * docs(ibex): correct httpErrorHandler comment — ibexResponse does not survive onto the returned error Round-3 review fix for PR #477: the comment claimed the uncapped body stays reachable on wrapped.ibexResponse, but wrapped is function-local and IbexError's constructor copies only name/httpCode/message — during a Cloudflare-outage scenario a maintainer would search for ibexResponse and find nothing. State reality: the uncapped body is deliberately dropped so pino never serializes a multi-KB blob. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH --------- Co-authored-by: Dread <bobodread@bobodread.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause
IBEX answers an underfunded payment with a 400 and a JSON body like
{"error":"insufficient balance. Current Balance: 5.000000. Estimated Fee: 0.001109. invoice amount: 5.042164. account: ..."}, but the API returned only a generic error. Two stacked defects:src/services/ibex/errors.tsmatchede.message.includes("insufficient balance")— butibex-client@3.2.0'sApiErrormessage is just the wrappedFetchErrorstack (FetchError: Bad Request…). The JSON body lives onFetchError.dataand is discarded at wrap time (lnflash/ibex-client#6), so on the SDK pay path theInsufficientIbexBalancebranch was dead code. This also silently broke the fygarocredit-topuptreasury-float detection, which matches onInsufficientIbexBalanceby class.lnInvoicePaymentSendcollapsed everyIbexErrorsubclass into a hardcoded"An unexpected error occurred. Please try again later.", bypassingmapAndParseErrorForGqlResponse.Design
Works with the currently pinned
ibex-client@3.2.0— no dependency bumps:payInvoicenow invokes the generated SDK throughIbex.authentication.withAuthdirectly (both fields are public API;getIbexTokenalready usesIbex.authentication.storagethe same way), so the rawFetchError— parsed IBEX body on.data— reaches the newhttpErrorHandlerbefore ibex-client's wrapper can discard it.httpErrorHandlerclassifies insufficient-balance / payment-already-prepared from the body and wraps results in the existingIbexErrorhierarchy (httpCodepreserved viaApiError).errorHandleradditionally reads structured detail (duck-typedibexMessage/.data), with the existing message-text matching kept as fallback. Once a release containing lnflash/ibex-client#6's fix (lnflash/ibex-client#12, which puts the body onApiError.ibexResponse/ibexMessage) is adopted, every other call site picks up body-level classification with no further code change here.InsufficientIbexBalancenow carries a clean client-facing message — the full IBEX detail when available,"insufficient balance"otherwise. The error map forwardsmessageverbatim for theINSUFFICIENT_BALANCEcase, which previously would have shipped a raw stack trace to the client.lnInvoicePaymentSendbranches onInsufficientIbexBalance→mapAndParseErrorForGqlResponse(codeINSUFFICIENT_BALANCE); all other IBEX failures keep the exact existing generic message.error-map.tsneeded no changes — theInsufficientIbexBalance → INSUFFICIENT_BALANCEcase already existed.Tests
test/flash/unit/services/ibex/errors.spec.ts—ibexErrorDetailextraction (JSONerror/messagefields, plain-text body, structuredibexMessage, no-body);errorHandler(raw-fetch-style message match, future structured shape, pinned-version stack-only ApiError stays generic,CompletedInvoice,AuthenticationError, success passthrough);httpErrorHandler(insufficient-balance 400 JSON and non-JSON → typed error withhttpCode400 + full detail message; other 400s → genericIbexErrorunchanged; network errors → nohttpCode; non-Error throwables).test/flash/unit/services/ibex/pay-invoice.spec.ts—payInvoicewiring throughwithAuth: insufficient-balance 400 →InsufficientIbexBalance, other 400 → generic, network failure → generic, success passthrough.test/flash/unit/graphql/error-map.spec.ts—InsufficientIbexBalance→INSUFFICIENT_BALANCE(with detail and with fallback message, never a stack),mapAndParseErrorForGqlResponseshape, genericIbexError→IBEX_ERRORunchanged.test/flash/unit/graphql/ln-invoice-payment-send.spec.ts— resolver returns typedINSUFFICIENT_BALANCEfor insufficient balance, keeps the generic message for other IBEX errors, success path intact.Local checks: targeted unit suites (14 suites / 197 tests including adjacent
client-usd-walletand fygarocredit-topupconsumers) pass;yarn tsc-checkpasses;eslintclean on all changed files. No changes to.envoryarn.lock.Fixes #93
🤖 Generated with Claude Code
https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH