diff options
| author | Adam Soltys <[email protected]> | 2026-10-07 21:27:40 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-07 21:27:40 -0700 |
| commit | 654e489cb438c62c09d291cc781707323914f8f1 (patch) | |
| tree | 70720196609718e2c8b651ceeb9d350948044c1f /src/nostr.js | |
| parent | 6dfb59316865093eda01307ee3c95f87511cdf56 (diff) | |
| download | coinosv3-654e489cb438c62c09d291cc781707323914f8f1.tar.gz coinosv3-654e489cb438c62c09d291cc781707323914f8f1.zip | |
Gentler on relays: a relay that says rate-limited (CLOSED/OK/NOTICE) gets no one-shot reads for 30 s, doubling to 10 min; zap/reply tallies flush at most every 2 s in one REQ (two filters) and the live #e sub swaps at most every 10 s; quoted-note lookups batch per relay over 150 ms — scrolling a 300-follow feed went from ~21 to ~8.5 REQs per 10 s on the busiest relay, primal's "too many concurrent REQs" from 19 to 0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PCGjEEMD23CweKrefbTDJp
Diffstat (limited to 'src/nostr.js')
| -rw-r--r-- | src/nostr.js | 91 |
1 files changed, 89 insertions, 2 deletions
diff --git a/src/nostr.js b/src/nostr.js index a3cab53..2ab0b05 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -150,6 +150,61 @@ export function markRelaySick(url, why) { resetRelay(url); } const isSick = (u) => sick.has(relayKey(u)); + +// ---- rate limits ---------------------------------------------------------- +// A relay that answers "rate-limited:" (a CLOSED on a REQ, an OK false on a +// publish, or a NOTICE asking us to slow down) is up, just tired of us — +// asking again at the same pace is how an IP ends up banned (nos.lol and +// damus both did, 2026-10). So one-shot reads skip it for a while: 30 s the +// first time, doubling while it keeps refusing (ten minutes at most), and a +// quiet stretch forgives the count. Live subscriptions and publishes still go +// to it — they're few, and dropping a DM inbox relay costs more than a REQ. +const COOL_MS = 30_000; +const COOL_MAX_MS = 10 * 60_000; +const cooling = new Map(); // normalized url -> { until, n } +const RATE_LIMITED = /rate.?limit|slow down|too (many|fast)|throttl/i; +export function markRelayRateLimited(url, why) { + if (!url) return; + const n = relayKey(url); + const prev = cooling.get(n); + const now = Date.now(); + // a strike long after the last cool-off starts over + const strikes = prev && now - prev.until < COOL_MAX_MS ? prev.n + 1 : 1; + if (prev && prev.until > now) return; // already cooling; one burst is one strike + const ms = Math.min(COOL_MAX_MS, COOL_MS * 2 ** (strikes - 1)); + console.warn(`nostr: ${url} rate-limited us (${why}) — no queries for ${Math.round(ms / 1000)} s`); + cooling.set(n, { until: now + ms, n: strikes }); +} +const isCooling = (u) => { const c = cooling.get(relayKey(u)); return !!c && c.until > Date.now(); }; +// Every relay the pool opens reports its refusals here: wrap its subscribe / +// publish / NOTICE once, so every caller (ours and the pool's) is covered. +const _ensureRelay = pool.ensureRelay.bind(pool); +pool.ensureRelay = async (url, params) => { + const relay = await _ensureRelay(url, params); + if (relay && !relay._coinosRL) { + relay._coinosRL = true; + const notice = relay.onnotice; + relay.onnotice = (msg) => { + if (RATE_LIMITED.test(String(msg))) markRelayRateLimited(relay.url, String(msg)); + try { notice?.call(relay, msg); } catch {} + }; + const subscribe = relay.subscribe.bind(relay); + relay.subscribe = (filters, p = {}) => subscribe(filters, { + ...p, + onclose: (reason) => { + if (RATE_LIMITED.test(String(reason || ''))) markRelayRateLimited(relay.url, String(reason)); + p.onclose?.(reason); + }, + }); + const publish = relay.publish.bind(relay); + relay.publish = (evt) => publish(evt).catch((e) => { + const msg = e?.message || String(e || ''); + if (RATE_LIMITED.test(msg)) markRelayRateLimited(relay.url, msg); + throw e; + }); + } + return relay; +}; // Back online or back on screen: the home relays get a fresh chance at once // (verdicts saved by older builds could still be hours long). function forgiveHomeRelays() { @@ -177,6 +232,12 @@ function liveRelays(list) { const ok = (list || []).filter((u) => !isSick(u)); return ok.length ? ok : (list || []); } +// For one-shot reads: also leave out relays cooling off a rate limit. An +// empty answer is fine here — a query nobody can take now just finds nothing. +function readRelays(list) { + const live = liveRelays(list); + return live.filter((u) => !isCooling(u)); +} // Dial a relay once in the background to learn whether it's reachable; a // rejection here is the only reliable signal nostr-tools gives us. const probed = new Set(); @@ -224,6 +285,8 @@ export function resetRelay(url) { } // the relays a subscription on this list actually dials (sick ones skipped) export const liveRelayList = (list) => liveRelays(list); +// ...and the ones a one-shot read goes to (rate-limited ones skipped too) +export const readRelayList = (list) => readRelays(list); export function subscribeOn(relays, filter, onEvent, extra = {}) { try { @@ -241,7 +304,31 @@ export function subscribeOn(relays, filter, onEvent, extra = {}) { export async function queryOn(relays, filter, maxWait = 1500) { probeRelays(relays); try { - const evs = await pool.querySync(liveRelays(relays), filter, { maxWait }); + const live = readRelays(relays); + if (!live.length) return []; + const evs = await pool.querySync(live, filter, { maxWait }); + const oks = await verifyEventsAsync(evs); + return evs.filter((_, i) => oks[i]); + } catch { return []; } +} +// Several filters in ONE REQ per relay (NIP-01 allows a list) — one +// subscription against the relay's REQ budget instead of one per filter. +export async function queryManyOn(relays, filters, maxWait = 1500) { + probeRelays(relays); + try { + const live = readRelays(relays); + if (!live.length || !filters.length) return []; + const evs = await new Promise((resolve) => { + const got = []; + let sub = null, done = false; + const finish = () => { if (!done) { done = true; resolve(got); } }; + sub = pool.subscribeMap(live.flatMap((url) => filters.map((filter) => ({ url, filter }))), { + maxWait, + onevent: (e) => got.push(e), + oneose: () => { try { sub?.close('closed automatically on eose'); } catch {} finish(); }, + onclose: finish, + }); + }); const oks = await verifyEventsAsync(evs); return evs.filter((_, i) => oks[i]); } catch { return []; } @@ -252,7 +339,7 @@ export async function queryStreamOn(relays, filter, onEvent, maxWait = 1500) { probeRelays(relays); const pending = []; try { - const live = liveRelays(relays); + const live = readRelays(relays); if (!live.length) return; await new Promise((resolve) => { pool.subscribeManyEose(live, filter, { |
