Improve relay selection and fault tolerance for list fetching

- Updated `NostrClient.fetchListIncrementally` to prioritize user's NIP-65 read relays, cap concurrent requests, and temporarily mark failing relays as unreachable.
- Added `markRelayUnreachable` to `NostrClient` for temporary relay exclusion.
- Updated `SubscriptionsManager`, `HashtagPreferencesService`, and `UserBlockListManager` to use `relayManager.getReadRelayUrls()` for selecting relays, ensuring consistency with user preferences.
- Added structured logging for relay selection in `fetchListIncrementally`.
- Updated unit tests to mock `relayManager` behavior correctly.

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-01-27 22:54:31 +00:00
1 parent 281781f997
commit 5b69722f72
7 files changed
+114 -76

No files matched your search

+43 -1
View File
@@ -3580,7 +3580,29 @@ export class NostrClient {
? relayUrls
: this.relays;
const normalizedRelays = sanitizeRelayList(this.getHealthyRelays(relaysToUse));
const healthyCandidates = this.getHealthyRelays(relaysToUse);
const readPreferences = new Set(Array.isArray(this.readRelays) ? this.readRelays : []);
// Sort relays: prefer user's read relays first
const sortedRelays = healthyCandidates.sort((a, b) => {
const aPreferred = readPreferences.has(a);
const bPreferred = readPreferences.has(b);
if (aPreferred && !bPreferred) return -1;
if (!aPreferred && bPreferred) return 1;
return 0;
});
// Cap the number of relays to avoid excessive requests
const cappedRelays = sortedRelays.slice(0, 8);
const normalizedRelays = sanitizeRelayList(cappedRelays);
if (isDevMode) {
devLogger.log("[fetchListIncrementally] Selected relays:", {
count: normalizedRelays.length,
relays: normalizedRelays,
});
}
const results = [];
const concurrencyLimit = 8;
@@ -3706,6 +3728,7 @@ export class NostrClient {
}
} else {
devLogger.warn(`[fetchListIncrementally] Fetch failed for ${relayUrl}`, error);
this.markRelayUnreachable(relayUrl);
return { error, ok: false };
}
}
@@ -4084,6 +4107,25 @@ export class NostrClient {
return results;
}
markRelayUnreachable(url, ttlMs = 60000) {
if (!url || typeof url !== "string") {
return;
}
const normalized = url.trim();
if (!normalized) {
return;
}
this.unreachableRelays.add(normalized);
if (ttlMs > 0) {
setTimeout(() => {
this.unreachableRelays.delete(normalized);
if (isDevMode) {
devLogger.debug(`[nostr] Cleared temporary failure mark for ${normalized}`);
}
}, ttlMs);
}
}
getHealthyRelays(candidates) {
const source = Array.isArray(candidates) ? candidates : this.relays;
if (!this.unreachableRelays.size) {
+3 -20
View File
@@ -18,6 +18,7 @@ import { userLogger, devLogger } from "../utils/logger.js";
import { normalizeHashtag } from "../utils/hashtagNormalization.js";
import { profileCache } from "../state/profileCache.js";
import { DEFAULT_RELAY_URLS } from "../nostr/toolkit.js";
import { relayManager } from "../relayManager.js";
const LOG_PREFIX = "[HashtagPreferences]";
const HASHTAG_IDENTIFIER = "bitvid:tag-preferences";
@@ -486,26 +487,8 @@ class HashtagPreferencesService {
return;
}
const relaySet = new Set();
const addRelays = (candidate) => {
const sanitized = sanitizeRelayList(candidate);
for (const relay of sanitized) {
relaySet.add(relay);
}
};
addRelays(nostrClient.relays);
if (!relaySet.size) {
addRelays(nostrClient.readRelays);
}
if (!relaySet.size) {
addRelays(nostrClient.writeRelays);
}
if (!relaySet.size) {
addRelays(DEFAULT_RELAY_URLS);
}
const relays = Array.from(relaySet);
const readRelays = relayManager.getReadRelayUrls();
const relays = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS);
if (!relays.length) {
if (wasLoadedForUser) {
userLogger.warn(
+3 -30
View File
@@ -11,6 +11,7 @@ import {
subscribeVideoViewEvents,
} from "./nostrViewEventsFacade.js";
import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js";
import { relayManager } from "./relayManager.js";
import {
buildSubscriptionListEvent,
SUBSCRIPTION_LIST_IDENTIFIER,
@@ -429,36 +430,8 @@ class SubscriptionsManager {
try {
const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey;
const relaySet = new Set();
const addRelayCandidates = (candidates) => {
if (!candidates) {
return;
}
const iterable = Array.isArray(candidates)
? candidates
: candidates instanceof Set
? Array.from(candidates)
: [];
for (const candidate of iterable) {
if (typeof candidate !== "string") {
continue;
}
const trimmed = candidate.trim();
if (trimmed) {
relaySet.add(trimmed);
}
}
};
addRelayCandidates(nostrClient.relays);
if (!relaySet.size) {
addRelayCandidates(nostrClient.readRelays);
}
if (!relaySet.size) {
addRelayCandidates(DEFAULT_RELAY_URLS);
}
const relayUrls = Array.from(relaySet);
const readRelays = relayManager.getReadRelayUrls();
const relayUrls = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS);
if (!relayUrls.length) {
devLogger.warn(
"[SubscriptionsManager] No relay URLs available while loading subscriptions.",
+3 -20
View File
@@ -19,6 +19,7 @@ import {
} from "./nostrPublish.js";
import { profileCache } from "./state/profileCache.js";
import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js";
import { relayManager } from "./relayManager.js";
class TinyEventEmitter {
constructor() {
@@ -768,26 +769,8 @@ class UserBlockListManager {
}
};
const relaySet = new Set();
const addRelays = (candidate) => {
const sanitized = sanitizeRelayList(candidate);
for (const relay of sanitized) {
relaySet.add(relay);
}
};
addRelays(nostrClient.relays);
if (!relaySet.size) {
addRelays(nostrClient.readRelays);
}
if (!relaySet.size) {
addRelays(nostrClient.writeRelays);
}
if (!relaySet.size) {
addRelays(DEFAULT_RELAY_URLS);
}
const relays = Array.from(relaySet);
const readRelays = relayManager.getReadRelayUrls();
const relays = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS);
emitStatus({ status: "loading", relays });
const localBlocks = this._loadLocal(normalized);
+14
View File
@@ -10,6 +10,7 @@ const [{ nostrClient }, { setActiveSigner, clearActiveSigner }] =
import("../js/nostrClientFacade.js"),
import("../js/nostr/client.js"),
]);
const { relayManager } = await import("../js/relayManager.js");
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
@@ -22,6 +23,7 @@ const originalRelays = Array.isArray(nostrClient.relays)
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
? [...nostrClient.writeRelays]
: nostrClient.writeRelays;
const originalRelayEntries = relayManager.getEntries();
const originalWindowNostr = window.nostr;
function restoreNostrClient() {
@@ -34,9 +36,14 @@ function restoreNostrClient() {
: originalWriteRelays;
}
function restoreRelayManager() {
relayManager.setEntries(originalRelayEntries, { allowEmpty: true, updateClient: false });
}
test.beforeEach(async () => {
hashtagPreferences.reset();
restoreNostrClient();
restoreRelayManager();
window.nostr = originalWindowNostr;
// Setup default mock signer to prevent auto-save errors
@@ -68,6 +75,7 @@ test.beforeEach(async () => {
test.after(() => {
restoreNostrClient();
restoreRelayManager();
window.nostr = originalWindowNostr;
clearActiveSigner();
});
@@ -116,6 +124,7 @@ test(
};
nostrClient.relays = ["wss://relay.one"];
nostrClient.writeRelays = ["wss://relay.one"];
relayManager.setEntries([{ url: "wss://relay.one", mode: "both" }], { allowEmpty: false, updateClient: false });
await hashtagPreferences.load(pubkey);
@@ -156,6 +165,7 @@ test(
};
nostrClient.relays = ["wss://relay.two"];
nostrClient.writeRelays = ["wss://relay.two"];
relayManager.setEntries([{ url: "wss://relay.two", mode: "both" }], { allowEmpty: false, updateClient: false });
await hashtagPreferences.load(pubkey);
@@ -201,6 +211,7 @@ test(
};
nostrClient.relays = ["wss://relay.retry"];
nostrClient.writeRelays = ["wss://relay.retry"];
relayManager.setEntries([{ url: "wss://relay.retry", mode: "both" }], { allowEmpty: false, updateClient: false });
await hashtagPreferences.load(pubkey);
@@ -259,6 +270,7 @@ test(
};
nostrClient.relays = ["wss://relay.publish"];
nostrClient.writeRelays = ["wss://relay.publish"];
relayManager.setEntries([{ url: "wss://relay.publish", mode: "both" }], { allowEmpty: false, updateClient: false });
await hashtagPreferences.load(pubkey);
hashtagPreferences.addInterest("TagA");
@@ -331,6 +343,7 @@ test(
};
nostrClient.relays = ["wss://relay.window"];
nostrClient.writeRelays = ["wss://relay.window"];
relayManager.setEntries([{ url: "wss://relay.window", mode: "both" }], { allowEmpty: false, updateClient: false });
await hashtagPreferences.load(pubkey);
hashtagPreferences.addInterest("WindowTag");
@@ -420,6 +433,7 @@ test(
};
nostrClient.relays = ["wss://relay.tie"];
nostrClient.writeRelays = ["wss://relay.tie"];
relayManager.setEntries([{ url: "wss://relay.tie", mode: "both" }], { allowEmpty: false, updateClient: false });
await hashtagPreferences.load(pubkey);
+5
View File
@@ -11,6 +11,7 @@ import nostrService from "../js/services/nostrService.js";
import moderationService from "../js/services/moderationService.js";
import { getNostrEventSchema, NOTE_TYPES } from "../js/nostrEventSchemas.js";
import { profileCache } from "../js/state/profileCache.js";
import { relayManager } from "../js/relayManager.js";
test("loadSubscriptions aggregates relay results when one rejects", async () => {
const SubscriptionsManager = subscriptions.constructor;
@@ -39,6 +40,9 @@ test("loadSubscriptions aggregates relay results when one rejects", async () =>
nostrClient.relays = relayUrls;
nostrClient.writeRelays = relayUrls;
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
const eventsByRelay = {
"wss://relay-a.example": [
{
@@ -120,6 +124,7 @@ test("loadSubscriptions aggregates relay results when one rejects", async () =>
);
}
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
nostrClient.relays = originalRelays;
nostrClient.writeRelays = originalWriteRelays;
nostrClient.pool = originalPool;
+43 -5
View File
@@ -8,6 +8,7 @@ const {
const { setActiveSigner, clearActiveSigner, getActiveSigner } = await import(
"../js/nostr/client.js"
);
const { relayManager } = await import("../js/relayManager.js");
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
@@ -33,6 +34,9 @@ await (async () => {
"wss://relay-three.example",
];
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relays.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
const originalNostr = window.nostr;
const originalPool = nostrClient.pool;
const originalRelays = Array.isArray(nostrClient.relays)
@@ -147,6 +151,7 @@ await (async () => {
"decrypted block list should hydrate from newest event"
);
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
userBlocks.blockedPubkeys = originalBlocked;
userBlocks.blockEventId = originalBlockEventId;
userBlocks.muteEventId = originalMuteEventId;
@@ -170,6 +175,9 @@ await (async () => {
"wss://background.example",
];
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relays.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
const originalNostr = window.nostr;
const originalPool = nostrClient.pool;
const originalRelays = Array.isArray(nostrClient.relays)
@@ -302,6 +310,7 @@ await (async () => {
} finally {
unsubscribeStatus?.();
unsubscribeChange?.();
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
userBlocks.blockedPubkeys = originalBlocked;
userBlocks.blockEventId = originalBlockEventId;
userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt;
@@ -520,7 +529,11 @@ await (async () => {
const signedEvents = [];
nostrClient.relays = ["wss://direct-signer.example"];
const relayUrls = ["wss://direct-signer.example"];
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
nostrClient.relays = relayUrls;
nostrClient.writeRelays = nostrClient.relays;
nostrClient.pool = {
publish: (_targets, event) => {
@@ -662,6 +675,7 @@ await (async () => {
"mute list event should include p-tags for blocked pubkeys",
);
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
userBlocks.blockedPubkeys = originalBlocked;
userBlocks.blockEventId = originalBlockEventId;
userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt;
@@ -866,6 +880,9 @@ await (async () => {
clearActiveSigner();
const relayUrls = ["wss://relay-nip44-read.example"];
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
nostrClient.relays = relayUrls;
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
@@ -908,6 +925,7 @@ await (async () => {
assert.equal(decryptCalls.nip44, 1, "nip44 decryptor should be preferred");
assert.equal(decryptCalls.nip04, 0, "nip04 decryptor should not be used");
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
window.nostr = originalNostr;
nostrClient.relays = originalRelays;
nostrClient.pool = originalPool;
@@ -1088,7 +1106,11 @@ await (async () => {
const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays;
const originalPool = nostrClient.pool;
nostrClient.relays = ["wss://tag-only.example"];
const relayUrls = ["wss://tag-only.example"];
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
nostrClient.relays = relayUrls;
nostrClient.pool = {
list: async () => [
{
@@ -1109,6 +1131,7 @@ await (async () => {
assert.equal(manager.blockedPubkeys.size, 1, "should have exactly one blocked pubkey");
assert.equal(manager.blockEventId, "event-tag-only", "should track the correct event ID");
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
nostrClient.relays = originalRelays;
nostrClient.pool = originalPool;
}
@@ -1125,7 +1148,11 @@ await (async () => {
const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays;
const originalPool = nostrClient.pool;
nostrClient.relays = ["wss://tag-only-whitespace.example"];
const relayUrls = ["wss://tag-only-whitespace.example"];
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
nostrClient.relays = relayUrls;
nostrClient.pool = {
list: async () => [
{
@@ -1144,6 +1171,7 @@ await (async () => {
assert(manager.blockedPubkeys.has(blockedHex), "should load blocked pubkeys from p-tags when content is whitespace");
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
nostrClient.relays = originalRelays;
nostrClient.pool = originalPool;
}
@@ -1161,7 +1189,11 @@ await (async () => {
const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays;
const originalPool = nostrClient.pool;
nostrClient.relays = ["wss://empty-clear.example"];
const relayUrls = ["wss://empty-clear.example"];
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
nostrClient.relays = relayUrls;
nostrClient.pool = {
list: async () => [
{
@@ -1180,6 +1212,7 @@ await (async () => {
assert.equal(manager.blockedPubkeys.size, 0, "should clear block list when content and tags are empty");
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
nostrClient.relays = originalRelays;
nostrClient.pool = originalPool;
}
@@ -1195,7 +1228,11 @@ await (async () => {
const originalPool = nostrClient.pool;
const originalNostr = window.nostr;
nostrClient.relays = ["wss://repro.example"];
const relayUrls = ["wss://repro.example"];
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
nostrClient.relays = relayUrls;
window.nostr = {
nip04: {
@@ -1238,6 +1275,7 @@ await (async () => {
assert.ok(blocked.includes(taggedBlocked), "Should include pubkey from tagged list");
assert.ok(!blocked.includes(plainBlocked), "Should NOT include pubkey from plain list (fallback ignored)");
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
nostrClient.relays = originalRelays;
nostrClient.pool = originalPool;
window.nostr = originalNostr;