diff --git a/js/nostr/client.js b/js/nostr/client.js index a6279725..ff0082e5 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -8608,9 +8608,18 @@ export class NostrClient { this.countUnsupportedRelays.add(url); } else { logRelayCountFailure(url, error); - this.markRelayUnreachable(url, 60000, { - reason: isTimeout ? "count-timeout" : "count-error", - }); + // If the count request timed out, we don't want to kill the entire relay connection + // because it might still be good for subscriptions. + // We only trip the circuit breaker for hard errors. + if (!isTimeout) { + this.markRelayUnreachable(url, 60000, { + reason: "count-error", + }); + } else if (isDevMode) { + devLogger.warn( + `[nostr] Relay ${url} count timed out (ignored for circuit breaker).` + ); + } } return { url, diff --git a/tests/unit/client-count-resilience.test.mjs b/tests/unit/client-count-resilience.test.mjs new file mode 100644 index 00000000..41ccb679 --- /dev/null +++ b/tests/unit/client-count-resilience.test.mjs @@ -0,0 +1,57 @@ + +import { test } from "node:test"; +import assert from "node:assert"; +import { NostrClient } from "../../js/nostr/client.js"; + +// Mock global objects if needed +if (!globalThis.localStorage) { + globalThis.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }; +} + +test("NostrClient resilience to COUNT timeouts", async (t) => { + const client = new NostrClient(); + const relayUrl = "wss://slow.relay.example"; + client.relays = [relayUrl]; + + // Mock the pool + client.pool = { + ensureRelay: async (url) => { + return { + url, + count: async () => { + // Simulate timeout by waiting longer than the client's timeout + await new Promise(r => setTimeout(r, 100)); + // In the real code, withRequestTimeout throws the error, the relay just hangs or returns late. + // But here we can simulate the client's wrapper throwing 'count-timeout' + // Actually, client.sendRawCountFrame handles the timeout wrapping. + // So we just need a promise that hangs. + return new Promise(() => {}); + } + }; + } + }; + + // We need to override getRequestTimeoutMs to be very short for the test + client.getRequestTimeoutMs = () => 10; // 10ms timeout + + // Verify initial state + assert.strictEqual(client.unreachableRelays.has(relayUrl), false); + + // Trigger the count + await client.countEventsAcrossRelays([{ kinds: [1] }]); + + // Check if relay was marked unreachable + // logic: + // 1. sendRawCountFrame calls withRequestTimeout + // 2. timeouts after 10ms -> throws 'count-timeout' + // 3. countEventsAcrossRelays catches 'count-timeout' + // 4. currently calls markRelayUnreachable + + const isUnreachable = client.unreachableRelays.has(relayUrl); + + assert.strictEqual(isUnreachable, false, "Relay should NOT be marked unreachable after count timeout"); +});