From e2556617ef4c1632217b3731afe8e519369cbeab Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 15 Jun 2026 15:31:53 -0700 Subject: Prefer mempool, cooldown 429'ing hosts (stop Blockstream storm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-robin sent half of every scan to Blockstream, which hard-rate-limits this IP (429 on each), and we kept routing back to it — stalling scans so new balances never showed. Now hosts are tried in order (mempool first) and any host that 429s is parked for 30s, so traffic stays on the healthy host and Blockstream is fallback-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api.js | 49 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 16 deletions(-) (limited to 'src') diff --git a/src/api.js b/src/api.js index 1c70640..4a8b60c 100644 --- a/src/api.js +++ b/src/api.js @@ -16,8 +16,9 @@ export class Api { this.net = net; this.offline = false; - this._hosts = BACKENDS[net] || BACKENDS.mainnet; - this._rr = 0; + // 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 })); // Serialized scheduler: one request at a time, min gap between starts. this._active = 0; @@ -81,12 +82,15 @@ export class Api { } } - // The single choke-point: fetch through the scheduler with 429 handling. - async #run(url, opts) { + // The single choke-point: fetch a given host through the scheduler. A 429 + // parks that host on cooldown (so we route away from it) and triggers the + // global backoff pause. + async #run(host, path, opts) { if (this.offline) throw new Error('offline'); return this.#schedule(async () => { - const res = await fetch(url, opts); + const res = await fetch(host.base + path, opts); if (res.status === 429) { + host.cooldownUntil = Date.now() + 30000; this.#penalize(); const e = new Error('rate limited'); e.rateLimited = true; @@ -97,21 +101,30 @@ export class Api { }); } - #host() { - return this._hosts[this._rr++ % this._hosts.length]; + // First host not on cooldown (mempool preferred), or null if all are cooling. + #pickHost() { + const now = Date.now(); + for (const h of this._hosts) if (h.cooldownUntil <= now) return h; + return null; } async #get(path, asText = false) { + if (this.offline) throw new Error('offline'); let lastErr; for (let attempt = 0; attempt < 6; attempt++) { + let host = this.#pickHost(); + if (!host) { + // Every host is cooling down — wait for the soonest to recover. + const soonest = Math.min(...this._hosts.map((h) => h.cooldownUntil)); + await sleep(Math.max(300, soonest - Date.now())); + host = this.#pickHost() || this._hosts[0]; + } try { - const res = await this.#run(this.#host() + path); + const res = await this.#run(host, path); if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${path}`); return asText ? await res.text() : await res.json(); } catch (e) { lastErr = e; - // On a 429 the global pause already delays the retry; for other errors - // add a small wait before trying the other host. if (!e.rateLimited) await sleep(300); } } @@ -132,10 +145,11 @@ export class Api { } async feeRates() { - for (const base of this._hosts) { - const isMempool = base.includes('mempool'); + for (const host of this._hosts) { + if (host.cooldownUntil > Date.now()) continue; + const isMempool = host.base.includes('mempool'); try { - const res = await this.#run(base + (isMempool ? '/v1/fees/recommended' : '/fee-estimates')); + const res = await this.#run(host, isMempool ? '/v1/fees/recommended' : '/fee-estimates'); if (!res.ok) continue; const data = await res.json(); if (isMempool && data && data.halfHourFee) return data; @@ -152,10 +166,13 @@ export class Api { // watches. Resolves with the txid if any accept it; rejects only if all fail. async broadcast(hexTx) { if (this.offline) throw new Error('offline'); - const attempts = this._hosts.map(async (base) => { - const res = await fetch(base + '/tx', { method: 'POST', body: hexTx }); + const attempts = this._hosts.map(async (host) => { + const res = await fetch(host.base + '/tx', { method: 'POST', body: hexTx }); const body = await res.text(); - if (res.status === 429) this.#penalize(); + if (res.status === 429) { + host.cooldownUntil = Date.now() + 30000; + this.#penalize(); + } if (!res.ok) throw new Error(body || `${res.status} broadcast failed`); return body.trim(); // txid }); -- cgit v1.2.3