diff --git a/js/payments/lnurl.js b/js/payments/lnurl.js index 00a9cc5f..b3e6ef0b 100644 --- a/js/payments/lnurl.js +++ b/js/payments/lnurl.js @@ -228,10 +228,42 @@ function normalizeNumeric(value, fallback = 0) { return numeric; } -export async function fetchPayServiceData(url, { fetcher } = {}) { +// LNURL servers (Strike among them) intermittently return transient failures — +// network errors, 5xx, or {status:"ERROR"} bodies like "Could not get user +// information" — for addresses that resolve fine seconds later (verified live +// 2026-07-04 against strike.me: ERROR on one request, OK with a callback on +// the retry). One short retry keeps a real zap from dying on such a blip; a +// genuinely bad address still fails after at most one extra request. +export async function fetchPayServiceData( + url, + { fetcher, retries = 1, retryDelayMs = 600 } = {}, +) { const targetUrl = sanitizeUrl(url); const fetchFn = ensureFetchFunction(fetcher); + let lastError = null; + for (let attempt = 0; attempt <= retries; attempt += 1) { + if (attempt > 0 && retryDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + try { + return await fetchPayServiceDataOnce(targetUrl, fetchFn); + } catch (error) { + lastError = error; + if (error?.transient !== true) { + throw error; + } + } + } + throw lastError; +} + +function markTransient(error) { + error.transient = true; + return error; +} + +async function fetchPayServiceDataOnce(targetUrl, fetchFn) { let response; try { response = await fetchFn(targetUrl, { @@ -247,18 +279,21 @@ export async function fetchPayServiceData(url, { fetcher } = {}) { ); friendly.cause = error; friendly.code = "lnurl-unreachable"; - throw friendly; + throw markTransient(friendly); } if (!response.ok) { - throw new Error(`Failed to load LNURL metadata (${response.status}).`); + throw markTransient( + new Error(`Failed to load LNURL metadata (${response.status}).`), + ); } let payload; try { payload = await response.json(); } catch (error) { - throw new Error("LNURL endpoint did not return JSON."); + // A non-JSON body is usually an edge/CDN interstitial — treat as a blip. + throw markTransient(new Error("LNURL endpoint did not return JSON.")); } if (!payload || typeof payload !== "object") { @@ -270,7 +305,7 @@ export async function fetchPayServiceData(url, { fetcher } = {}) { typeof payload.reason === "string" && payload.reason.trim() ? payload.reason.trim() : "LNURL endpoint returned an error."; - throw new Error(reason); + throw markTransient(new Error(reason)); } const callback = sanitizeUrl(payload.callback); diff --git a/tests/lnurl-fetch-error.test.mjs b/tests/lnurl-fetch-error.test.mjs index 2f7493bf..e451f27c 100644 --- a/tests/lnurl-fetch-error.test.mjs +++ b/tests/lnurl-fetch-error.test.mjs @@ -12,7 +12,11 @@ test("network/CORS failure throws a friendly, coded error", async () => { throw new TypeError("Failed to fetch"); }; await assert.rejects( - () => fetchPayServiceData("https://example.com/.well-known/lnurlp/x", { fetcher }), + () => + fetchPayServiceData("https://example.com/.well-known/lnurlp/x", { + fetcher, + retryDelayMs: 0, + }), (err) => { assert.match(err.message, /reach this recipient's Lightning address/i); assert.equal(err.code, "lnurl-unreachable"); @@ -25,11 +29,67 @@ test("network/CORS failure throws a friendly, coded error", async () => { test("a non-OK HTTP response still reports a status error (unchanged)", async () => { const fetcher = async () => ({ ok: false, status: 502 }); await assert.rejects( - () => fetchPayServiceData("https://example.com/.well-known/lnurlp/x", { fetcher }), + () => + fetchPayServiceData("https://example.com/.well-known/lnurlp/x", { + fetcher, + retryDelayMs: 0, + }), /Failed to load LNURL metadata \(502\)/, ); }); +// LNURL servers intermittently return {status:"ERROR"} bodies (observed live +// against strike.me: "Could not get user information" on one request, a valid +// callback on the next). A single retry must absorb that blip; a PERSISTENT +// error must still surface the server's reason after the retry budget. +test("a transient LNURL ERROR body recovers on the retry", async () => { + let calls = 0; + const fetcher = async () => { + calls += 1; + if (calls === 1) { + return { + ok: true, + json: async () => ({ status: "ERROR", reason: "Could not get user information" }), + }; + } + return { + ok: true, + json: async () => ({ + tag: "payRequest", + callback: "https://example.com/cb", + minSendable: 1000, + maxSendable: 100000, + }), + }; + }; + const meta = await fetchPayServiceData( + "https://example.com/.well-known/lnurlp/x", + { fetcher, retryDelayMs: 0 }, + ); + assert.equal(meta.callback, "https://example.com/cb"); + assert.equal(calls, 2, "exactly one retry"); +}); + +test("a persistent LNURL ERROR still fails with the server's reason", async () => { + let calls = 0; + const fetcher = async () => { + calls += 1; + return { + ok: true, + json: async () => ({ status: "ERROR", reason: "Could not get user information" }), + }; + }; + await assert.rejects( + () => + fetchPayServiceData("https://example.com/.well-known/lnurlp/x", { + fetcher, + retryDelayMs: 0, + }), + /Could not get user information/, + ); + assert.equal(calls, 2, "retry budget spent, then surfaced"); +}); + test("a valid LNURL pay response parses (no false friendly error)", async () => { const fetcher = async () => ({ ok: true,