diff options
| author | Adam Soltys <[email protected]> | 2026-06-15 20:50:41 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-15 20:50:41 -0700 |
| commit | 36057966438b26327285e43ee00ce4895fd179c3 (patch) | |
| tree | 2f6e4f89113d00d87236bd0731ddcc46e573e1ad | |
| parent | c57f06e47eb5b662a00cddf27cd4270e8418d163 (diff) | |
| download | coinosv3-36057966438b26327285e43ee00ce4895fd179c3.tar.gz coinosv3-36057966438b26327285e43ee00ce4895fd179c3.zip | |
Add per-request timeout + failover for unresponsive hosts
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) <[email protected]>
| -rw-r--r-- | src/api.js | 26 |
1 files changed, 24 insertions, 2 deletions
@@ -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; |
