diff --git a/src/utils/daemon-client.retry.test.ts b/src/utils/daemon-client.retry.test.ts new file mode 100644 index 0000000..7737f9b --- /dev/null +++ b/src/utils/daemon-client.retry.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Each case loads config in an isolated process, without touching user config +// or sharing fetch mocks with other test files. +async function runCase(config: object, action: string, responses: number[]) { + const dir = mkdtempSync(join(tmpdir(), "routstrd-retry-")); + try { + writeFileSync(join(dir, "config.json"), JSON.stringify(config)); + const script = ` + const { callDaemon, callAuth, isDaemonRunning } = await import(${JSON.stringify(join(import.meta.dir, "daemon-client.ts"))}); + const responses = ${JSON.stringify(responses)}; + const requests = []; + globalThis.fetch = async (url, init) => { + requests.push({ url: String(url), method: init?.method ?? "GET" }); + const status = responses.shift(); + if (status === 0) throw new TypeError("Connection reset"); + if (status === undefined) throw new Error("Unexpected request"); + return Response.json(status >= 400 ? {error: "denied"} : {output: "ok"}, {status}); + }; + let result, error; + try { result = await (${action}); } catch (e) { error = e.message; } + console.log(JSON.stringify({ requests, result, error })); + `; + const proc = Bun.spawn([process.execPath, "--eval", script], { + env: { ...process.env, ROUTSTRD_DIR: dir }, stdout: "pipe", stderr: "pipe", + }); + const output = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + expect(await proc.exited, stderr).toBe(0); + return JSON.parse(output) as { + requests: { url: string; method: string }[]; + result?: unknown; + error?: string; + }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const remote = { daemonUrl: "http://localhost:18008" }; + +describe("daemon loopback retries", () => { + test("GET retries a connection failure", async () => { + const r = await runCase(remote, 'callDaemon("/status")', [0, 200]); + expect(r.result).toEqual({ output: "ok" }); + expect(r.requests.map(r => r.url)).toEqual([ + "http://localhost:18008/status", "http://127.0.0.1:18008/status", + ]); + }); + + for (const method of ["POST", "PATCH", "DELETE"]) { + for (const [name, config, client] of [ + ["remote", remote, "callDaemon"], + ["local", { host: "0.0.0.0", port: 18008 }, "callDaemon"], + ["auth", { authUrl: "http://localhost:18008" }, "callAuth"], + ] as const) { + test(`${name} ${method} is never replayed after a reset`, async () => { + const r = await runCase(config, `${client}("/wallet/send/cashu", {method: "${method}", body: {amount: 10}})`, [200, 0, 200]); + expect(r.error).toContain("outcome is unknown"); + expect(r.requests.map(r => r.method)).toEqual(["GET", method]); + }); + } + } + + test("selects IPv4 with health probes before sending POST once", async () => { + const r = await runCase(remote, 'callDaemon("/wallet/send/cashu", {method: "POST"})', [0, 200, 200]); + expect(r.result).toEqual({ output: "ok" }); + expect(r.requests).toEqual([ + { url: "http://localhost:18008/health", method: "GET" }, + { url: "http://127.0.0.1:18008/health", method: "GET" }, + { url: "http://127.0.0.1:18008/wallet/send/cashu", method: "POST" }, + ]); + }); + + test("a single-address write also reports an unknown outcome", async () => { + const r = await runCase({daemonUrl: "http://127.0.0.1:18008"}, 'callDaemon("/wallet/send/cashu", {method: "POST"})', [0]); + expect(r.error).toContain("outcome is unknown"); + expect(r.requests).toHaveLength(1); + }); + + test("write address selection stops on HTTP errors", async () => { + const r = await runCase(remote, 'callDaemon("/wallet/send/cashu", {method: "POST"})', [403, 200]); + expect(r.error).toBe("denied"); + expect(r.requests).toHaveLength(1); + }); + + for (const config of [remote, {host: "0.0.0.0", port: 18008}]) { + test(`health HTTP errors stop fallback (${JSON.stringify(config)})`, async () => { + const r = await runCase(config, "isDaemonRunning()", [403, 200]); + expect(r.result).toBe(false); + expect(r.requests).toHaveLength(1); + }); + } + + test("health connection failures still allow fallback", async () => { + const r = await runCase(remote, "isDaemonRunning()", [0, 200]); + expect(r.result).toBe(true); + expect(r.requests).toHaveLength(2); + }); +}); diff --git a/src/utils/daemon-client.ts b/src/utils/daemon-client.ts index 788e689..e5d8566 100644 --- a/src/utils/daemon-client.ts +++ b/src/utils/daemon-client.ts @@ -204,42 +204,59 @@ export async function callDaemonUrl( } } -async function callLocalDaemon( +/** Resolve loopback addresses with safe reads, never by replaying a write. */ +async function callDaemonCandidates( + candidates: string[], path: string, options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object }, config: RoutstrdConfig, ): Promise { - // Retry only connection failures; HTTP errors prove that a server answered. + const isRead = (options.method ?? "GET") === "GET"; let connectionError: DaemonConnectionError | undefined; - for (const baseUrl of localDaemonBaseUrls(config)) { + for (const baseUrl of candidates) { + if (!isRead && candidates.length > 1) { + try { + // Select an address before dispatching a potentially money-moving + // request. An HTTP error is authoritative, not a reason to fall back. + await callDaemonUrl(baseUrl, "/health", { method: "GET" }, config); + } catch (error) { + if (!(error instanceof DaemonConnectionError)) throw error; + connectionError = error; + continue; + } + } try { return await callDaemonUrl(baseUrl, path, options, config); } catch (error) { if (!(error instanceof DaemonConnectionError)) throw error; + if (!isRead) { + // fetch can reject after the server has committed the operation. + throw new Error( + "Connection lost; operation outcome is unknown — check its status before retrying", + { cause: error }, + ); + } connectionError = error; } } throw connectionError ?? new Error("No daemon host candidates available"); } -/** Call a configured remote endpoint, retrying connection failures against the - * other loopback family when the host is `localhost`. */ +async function callLocalDaemon( + path: string, + options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object }, + config: RoutstrdConfig, +): Promise { + return callDaemonCandidates(localDaemonBaseUrls(config), path, options, config); +} + async function callRemoteDaemon( baseUrl: string, path: string, options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object }, config: RoutstrdConfig, ): Promise { - let connectionError: DaemonConnectionError | undefined; - for (const candidate of baseUrlCandidates(baseUrl)) { - try { - return await callDaemonUrl(candidate, path, options, config); - } catch (error) { - if (!(error instanceof DaemonConnectionError)) throw error; - connectionError = error; - } - } - throw connectionError ?? new Error("No daemon host candidates available"); + return callDaemonCandidates(baseUrlCandidates(baseUrl), path, options, config); } export async function callDaemon( @@ -282,7 +299,7 @@ export async function isDaemonRunning(): Promise { const response = await fetch(url, { headers: authorization ? { Authorization: authorization } : {}, }); - if (response.ok) return true; + return response.ok; } catch { // Try the next candidate host. } @@ -298,7 +315,7 @@ export async function isDaemonRunning(): Promise { const response = await fetch(`${baseUrl}/health`, { signal: controller.signal, }); - if (response.ok) return true; + return response.ok; } catch { // Try the next candidate host. } finally {