diff options
| author | Adam Soltys <[email protected]> | 2026-06-15 15:31:53 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-15 15:31:53 -0700 |
| commit | e2556617ef4c1632217b3731afe8e519369cbeab (patch) | |
| tree | a754260b242fe0908287a79a87167401812f9fdd /src | |
| parent | adad0731862e6c07e400db010c566c548848d86c (diff) | |
| download | coinosv3-e2556617ef4c1632217b3731afe8e519369cbeab.tar.gz coinosv3-e2556617ef4c1632217b3731afe8e519369cbeab.zip | |
Prefer mempool, cooldown 429'ing hosts (stop Blockstream storm)
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) <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/api.js | 49 |
1 files changed, 33 insertions, 16 deletions
@@ -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 }); |
