aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-15 15:31:53 -0700
committerAdam Soltys <[email protected]>2026-06-15 15:31:53 -0700
commite2556617ef4c1632217b3731afe8e519369cbeab (patch)
treea754260b242fe0908287a79a87167401812f9fdd /src
parentadad0731862e6c07e400db010c566c548848d86c (diff)
downloadcoinosv3-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.js49
1 files changed, 33 insertions, 16 deletions
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
});