From 36057966438b26327285e43ee00ce4895fd179c3 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 15 Jun 2026 20:50:41 -0700 Subject: Add per-request timeout + failover for unresponsive hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With concurrency 1, a single hung request (no response) stalled the whole serialized queue indefinitely — so balances/notifications never updated and the console filled with ERR_TIMED_OUT. Each request now has a 10s abort timeout; on timeout/network error the host is parked (20s) and we fail over to the other explorer. Applies to broadcasts too. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api.js | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/api.js b/src/api.js index 4a8b60c..5d0da77 100644 --- a/src/api.js +++ b/src/api.js @@ -11,6 +11,8 @@ const BACKENDS = { testnet: ['https://mempool.space/testnet/api', 'https://blockstream.info/testnet/api'], }; +const REQUEST_TIMEOUT_MS = 10000; + export class Api { constructor(net = 'mainnet') { this.net = net; @@ -19,6 +21,7 @@ export class Api { // Hosts are tried in order (mempool first); a host that 429s is parked on // cooldown so we stop hammering it (Blockstream rate-limits aggressively). this._hosts = (BACKENDS[net] || BACKENDS.mainnet).map((base) => ({ base, cooldownUntil: 0 })); + this._timeoutMs = REQUEST_TIMEOUT_MS; // Serialized scheduler: one request at a time, min gap between starts. this._active = 0; @@ -88,7 +91,19 @@ export class Api { async #run(host, path, opts) { if (this.offline) throw new Error('offline'); return this.#schedule(async () => { - const res = await fetch(host.base + path, opts); + // Hard timeout: an unresponsive host must not stall the (serialized) + // queue. On timeout/network error, park the host and fail over. + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), this._timeoutMs); + let res; + try { + res = await fetch(host.base + path, { ...opts, signal: ctrl.signal }); + } catch (e) { + host.cooldownUntil = Date.now() + 20000; // unresponsive — route away + throw e; + } finally { + clearTimeout(timer); + } if (res.status === 429) { host.cooldownUntil = Date.now() + 30000; this.#penalize(); @@ -167,7 +182,14 @@ export class Api { async broadcast(hexTx) { if (this.offline) throw new Error('offline'); const attempts = this._hosts.map(async (host) => { - const res = await fetch(host.base + '/tx', { method: 'POST', body: hexTx }); + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), this._timeoutMs); + let res; + try { + res = await fetch(host.base + '/tx', { method: 'POST', body: hexTx, signal: ctrl.signal }); + } finally { + clearTimeout(timer); + } const body = await res.text(); if (res.status === 429) { host.cooldownUntil = Date.now() + 30000; -- cgit v1.2.3