mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
zaps: retry transient LNURL failures once before failing the zap
Verified live against strike.me: the SAME lnurlp endpoint returned
{status:"ERROR","reason":"Could not get user information"} on one request
and a valid callback moments later — the address was fine, the server
blipped. fetchPayServiceData now retries once (600ms) on the transient
classes (fetch rejection, non-OK HTTP, non-JSON body, ERROR-status body);
shape-invalid payloads still fail immediately, and a persistent error
surfaces the server's reason after the single retry.
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
e2d4251035
commit
ad62205afd
2 files changed
+102
-7
No files matched your search
+40
-5
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user