Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 6 additions & 21 deletions src/services/ibex/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,7 @@ import {
UsdWalletAmount,
} from "./types"

import {
errorHandler,
httpErrorHandler,
IbexError,
ParseError,
UnexpectedIbexResponse,
} from "./errors"
import { errorHandler, IbexError, ParseError, UnexpectedIbexResponse } from "./errors"
import { ibexWebhookEndpoints, ibexWebhookSecret } from "./webhook-config"

const Ibex = new IbexClient(
Expand Down Expand Up @@ -247,20 +241,11 @@ const payInvoice = async (
webhookSecret: ibexWebhookSecret,
} as PayInvoiceV2BodyParam
addAttributesToCurrentSpan({ "request.params": JSON.stringify(bodyWithHooks) })
// Call the generated SDK through withAuth directly (instead of
// Ibex.payInvoiceV2) so a failed payment's FetchError — which carries the
// parsed IBEX error body on `.data` — reaches httpErrorHandler intact.
// This seam predates ibex-client@3.3.0: 3.2.0's ApiError wrapper discarded
// that body (lnflash/ibex-client#6), which made "insufficient balance" 400s
// unclassifiable. As of 3.3.0, ApiError carries the body itself and
// errorHandler classifies it through the standard path, so the seam is
// redundant — retained only as belt-and-braces until it is collapsed back
// to `Ibex.payInvoiceV2(bodyWithHooks).then(errorHandler)` in its own PR
// (lnflash/flash#478; a payment-path change doesn't belong in a deps bump).
return Ibex.authentication
.withAuth(() => Ibex.ibex.payInvoiceV2(bodyWithHooks))
.then(errorHandler)
.catch(httpErrorHandler)
// Standard SDK path. A raw-fetch seam lived here while ibex-client 3.2.0's
// ApiError discarded the response body (lnflash/ibex-client#6); 3.3.0's
// ApiError carries the body itself (`ibexMessage`/`ibexResponse`/`httpCode`)
// and errorHandler classifies it, so the seam was collapsed (lnflash/flash#478).
return Ibex.payInvoiceV2(bodyWithHooks).then(errorHandler)
}

// onchain transactions are typically high-value
Expand Down
64 changes: 9 additions & 55 deletions src/services/ibex/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ export class CompletedInvoice extends IbexError {}
* - ibex-client > 3.2.0 (lnflash/ibex-client#6): ApiError carries the parsed
* body on `ibexResponse` and the extracted text on `ibexMessage`
* - the generated api SDK's FetchError: parsed JSON body on `.data`,
* typically `{ "error": "..." }`, sometimes `{ "message": "..." }` or text
* typically `{ "error": "..." }`, sometimes `{ "message": "..." }` or text.
* No live caller passes this shape anymore — the payInvoice raw-fetch seam
* was collapsed onto the SDK path (lnflash/flash#478) — but the fallback is
* a few lines and keeps this helper safe for any future raw caller.
*/
export const ibexErrorDetail = (e: unknown): string | undefined => {
if (typeof e !== "object" || e === null) return undefined
Expand All @@ -71,8 +74,8 @@ export const ibexErrorDetail = (e: unknown): string | undefined => {
// The single needle list mapping IBEX error prose to a typed error class.
// Matching is case-insensitive (the haystack is lowercased once here) so a
// vendor rewording like "Insufficient Balance" cannot silently revert
// classification to the generic path. Both errorHandler and httpErrorHandler
// classify through this helper — never add a needle anywhere else.
// classification to the generic path. errorHandler classifies through this
// helper — never add a needle anywhere else.
const classifyIbexErrorText = (
text: string,
): typeof InsufficientIbexBalance | typeof CompletedInvoice | undefined => {
Expand All @@ -89,9 +92,9 @@ export const errorHandler = <T>(
if (e instanceof ApiError) {
// Classify against the structured body detail when the error carries one
// (ibex-client >= 3.3.0's ApiError extracts it onto `ibexMessage`), and
// against `message` otherwise — flash's raw-fetch path embeds the body
// text in the message. Body-carrying shapes are checked first so a stack
// that happens to contain a needle can't misclassify.
// against `message` otherwise — a defensive fallback for shapes that only
// embed the body text in the message. Body-carrying shapes are checked
// first so a stack that happens to contain a needle can't misclassify.
const detail = ibexErrorDetail(e)
const classified = classifyIbexErrorText(detail ?? e.message)
if (classified === InsufficientIbexBalance)
Expand All @@ -113,52 +116,3 @@ export const errorHandler = <T>(
if (e instanceof IbexClientError) return new IbexError(e, ErrorLevel.Warn)
return e
}

/**
* Classify a raw error thrown by the generated IBEX SDK (or fetch) for call
* sites that invoke the SDK through `Ibex.authentication.withAuth` themselves
* and route the caught error here (the payInvoice raw-fetch seam). The seam
* predates ibex-client@3.3.0: 3.2.0's ApiError kept only `httpCode` and
* discarded the JSON error body that distinguishes e.g. "insufficient
* balance" from any other 400 (lnflash/ibex-client#6). As of 3.3.0, ApiError
* extracts the body itself (`ibexResponse` / `ibexMessage`) and errorHandler
* classifies it through the standard path — this handler remains as
* defense-in-depth for the raw-fetch seam until that seam is collapsed
* (lnflash/flash#478).
*/
export const httpErrorHandler = (e: unknown): IbexError => {
const raw = e instanceof Error ? e : new Error(String(e))
if (raw instanceof AuthenticationError) return new IbexError(raw, ErrorLevel.Critical)
// ApiError's constructor keeps `.status` as httpCode, which IbexError reads.
const wrapped = raw instanceof IbexClientError ? raw : new ApiError(raw)
// Derive the detail from `wrapped`, never from `raw`. For a raw FetchError,
// ApiError's own extraction (`ibexMessage`) is capped at ibex-client's
// MAX_IBEX_MESSAGE_LENGTH, while reading straight off `raw.data` is not:
// comparing an uncapped detail against the capped copy embedded in
// `wrapped.message` would defeat the dedupe guard below for any body over
// the cap — exactly the Cloudflare-HTML-error-page outage the cap exists
// for — and prepend the full multi-KB body onto every failing call's
// message. When `raw` is already an IbexClientError, wrapped === raw and
// the extraction is unchanged. The uncapped body exists only on the local
// ApiError's `ibexResponse` and does not survive onto the returned
// IbexError — by design: pino serializes own enumerable properties, and
// attaching a multi-KB body would recreate the log bloat the cap prevents.
const detail = ibexErrorDetail(wrapped)
const classified = classifyIbexErrorText(detail ?? raw.message)
if (classified === InsufficientIbexBalance)
return new InsufficientIbexBalance(wrapped, ErrorLevel.Info, detail)
if (classified === CompletedInvoice)
return new CompletedInvoice(wrapped, ErrorLevel.Info)
// Unclassified path: an unrecognized IBEX 400 must still log what IBEX
// actually said, not just "FetchError: Bad Request" + stack. The ApiError
// constructed above already embeds the extracted detail in its message
// (ibex-client >= 3.3.0), so only carry the detail when the message doesn't
// already contain it — never append the same text twice.
if (
detail !== undefined &&
!(raw instanceof IbexClientError) &&
!wrapped.message.includes(detail)
)
wrapped.message = `${detail}\n${wrapped.message}`
return new IbexError(wrapped, ErrorLevel.Warn)
}
Loading
Loading