Merge pull request #1982 from PR0M3TH3AN/codex/implement-relay-sanitization-and-health-filtering

Improve relay sanitization and health filtering
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-30 10:48:51 -05:00
commit 03475a808e
6 files changed
+76 -26

No files matched your search

+32
View File
@@ -275,6 +275,7 @@ class Application {
this.dmRelayHints = new Map();
this.authLoadingState = { profile: "idle", lists: "idle", dms: "idle" };
this.relayUiRefreshTimeout = null;
this.lastRelayHealthWarningAt = 0;
this.activeIntervals = [];
this.torrentStatusIntervalId = null;
this.torrentStatusNodes = null;
@@ -6567,6 +6568,35 @@ class Application {
});
}
checkRelayHealthWarning() {
if (!nostrClient || typeof nostrClient.getHealthyRelays !== "function") {
return false;
}
const relayCandidates = Array.isArray(nostrClient.relays) ? nostrClient.relays : [];
if (!relayCandidates.length) {
return false;
}
const healthyRelays = nostrClient.getHealthyRelays(relayCandidates);
if (healthyRelays.length) {
return false;
}
const now = Date.now();
const cooldownMs = 30000;
if (now - (this.lastRelayHealthWarningAt || 0) < cooldownMs) {
return true;
}
this.lastRelayHealthWarningAt = now;
this.showStatus(
"All configured relays are unhealthy. Data may be missing until a relay reconnects.",
{ autoHideMs: 12000, showSpinner: false },
);
return true;
}
/**
* Subscribe to videos (older + new) and render them as they come in.
*/
@@ -6586,6 +6616,7 @@ class Application {
this.loadVideosPromise = (async () => {
devLogger.log("Starting loadVideos... (forceFetch =", forceFetch, ")");
this.setFeedTelemetryContext("recent");
this.checkRelayHealthWarning();
const container = this.mountVideoListView();
const hasCachedVideos =
@@ -6641,6 +6672,7 @@ class Application {
async loadForYouVideos(forceFetch = false) {
devLogger.log("Starting loadForYouVideos... (forceFetch =", forceFetch, ")");
this.setFeedTelemetryContext("for-you");
this.checkRelayHealthWarning();
const container = this.mountVideoListView({ includeTags: false });
const hasCachedVideos =
+2 -7
View File
@@ -1,6 +1,7 @@
import Application from "./app.js";
import { nostrClient } from "./nostrClientFacade.js";
import { convertEventToVideo, getDTagValueFromTags } from "./nostr/index.js";
import { sanitizeRelayList } from "./nostr/nip46Client.js";
import { resolveVideoPointer } from "./utils/videoPointer.js";
import { devLogger, userLogger } from "./utils/logger.js";
import { nostrToolsReady } from "./nostrToolsBootstrap.js";
@@ -180,13 +181,7 @@ const buildRelayList = (relays) => {
...(Array.isArray(nostrClient.relays) ? nostrClient.relays : []),
];
const result = Array.from(
new Set(
combined
.map((relay) => (typeof relay === "string" ? relay.trim() : ""))
.filter(Boolean)
)
);
const result = sanitizeRelayList(combined);
diag?.emit("relay-list", { relays: result });
return result;
};
+30 -3
View File
@@ -415,6 +415,8 @@ const RELAY_RECONNECT_MAX_DELAY_MS = 60000;
const RELAY_RECONNECT_MAX_ATTEMPTS = 5;
const RELAY_CIRCUIT_BREAKER_THRESHOLD = 3;
const RELAY_CIRCUIT_BREAKER_COOLDOWN_MS = 10 * 60 * 1000;
const RELAY_FAILURE_WINDOW_MS = 5 * 60 * 1000;
const RELAY_FAILURE_WINDOW_THRESHOLD = 3;
const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
// We use the policy TTL, but currently the storage backend is hardcoded to IDB (with localStorage fallback).
// Future refactors should make EventsCacheStore dynamic based on CACHE_POLICIES[NOTE_TYPES.VIDEO_POST].storage.
@@ -1294,6 +1296,7 @@ export class NostrClient {
this.unreachableRelays = new Set();
this.relayBackoff = new Map();
this.relayFailureCounts = new Map();
this.relayFailureWindows = new Map();
this.relayCircuitBreakers = new Map();
this.relayReconnectTimer = null;
this.relayReconnectAttempt = 0;
@@ -4578,10 +4581,30 @@ export class NostrClient {
}
this.relayBackoff.delete(normalized);
this.relayFailureCounts.delete(normalized);
this.relayFailureWindows.delete(normalized);
this.unreachableRelays.delete(normalized);
this.relayCircuitBreakers.delete(normalized);
}
recordRelayFailureWindow(url) {
if (!url || typeof url !== "string") {
return 0;
}
const normalized = url.trim();
if (!normalized) {
return 0;
}
const now = Date.now();
const cutoff = now - RELAY_FAILURE_WINDOW_MS;
const history = this.relayFailureWindows.get(normalized) || [];
const filtered = history.filter(
(timestamp) => Number.isFinite(timestamp) && timestamp >= cutoff,
);
filtered.push(now);
this.relayFailureWindows.set(normalized, filtered);
return filtered.length;
}
markRelayUnreachable(url, ttlMs = 60000, { reason = null } = {}) {
if (!url || typeof url !== "string") {
return;
@@ -4590,6 +4613,7 @@ export class NostrClient {
if (!normalized) {
return;
}
const windowFailureCount = this.recordRelayFailureWindow(normalized);
const nextFailureCount = (this.relayFailureCounts.get(normalized) || 0) + 1;
this.relayFailureCounts.set(normalized, nextFailureCount);
const backoffMs = this.resolveRelayBackoffMs(nextFailureCount, ttlMs);
@@ -4601,7 +4625,7 @@ export class NostrClient {
reason,
});
this.unreachableRelays.add(normalized);
if (nextFailureCount >= RELAY_CIRCUIT_BREAKER_THRESHOLD) {
if (windowFailureCount >= RELAY_FAILURE_WINDOW_THRESHOLD) {
const now = Date.now();
const openUntil = now + RELAY_CIRCUIT_BREAKER_COOLDOWN_MS;
const existing = this.relayCircuitBreakers.get(normalized);
@@ -4609,17 +4633,18 @@ export class NostrClient {
existing && Number.isFinite(existing.openUntil)
? Math.max(existing.openUntil, openUntil)
: openUntil;
const circuitReason = reason || "windowed-failures";
this.relayCircuitBreakers.set(normalized, {
openUntil: nextOpenUntil,
failureCount: nextFailureCount,
reason,
reason: circuitReason,
});
if (isDevMode) {
devLogger.debug("[nostr] Circuit breaker opened for relay.", {
relay: normalized,
openUntil: nextOpenUntil,
failureCount: nextFailureCount,
reason,
reason: circuitReason,
});
}
}
@@ -4644,6 +4669,7 @@ export class NostrClient {
}
this.relayCircuitBreakers.delete(url);
this.relayFailureCounts.delete(url);
this.relayFailureWindows.delete(url);
this.unreachableRelays.delete(url);
this.relayBackoff.delete(url);
if (isDevMode) {
@@ -4662,6 +4688,7 @@ export class NostrClient {
}
this.relayBackoff.delete(url);
this.relayFailureCounts.delete(url);
this.relayFailureWindows.delete(url);
this.unreachableRelays.delete(url);
if (isDevMode) {
devLogger.debug(`[nostr] Relay backoff expired for ${url}.`);
+1 -8
View File
@@ -15,6 +15,7 @@ import {
assertAnyRelayAccepted,
} from "../nostrPublish.js";
import { userLogger, devLogger } from "../utils/logger.js";
import { sanitizeRelayList } from "../nostr/nip46Client.js";
import { normalizeHashtag } from "../utils/hashtagNormalization.js";
import { profileCache } from "../state/profileCache.js";
import { DEFAULT_RELAY_URLS } from "../nostr/toolkit.js";
@@ -98,14 +99,6 @@ function normalizeHexPubkey(pubkey) {
return null;
}
function sanitizeRelayList(candidate) {
return Array.isArray(candidate)
? candidate
.map((url) => (typeof url === "string" ? url.trim() : ""))
.filter(Boolean)
: [];
}
function normalizeEncryptionToken(value) {
if (typeof value !== "string") {
return "";
+10
View File
@@ -132,6 +132,10 @@ class RelayHealthService {
if (Number.isFinite(latencyMs)) {
state.lastLatencyMs = Math.max(0, Math.round(latencyMs));
}
if (this.nostrClient && typeof this.nostrClient.clearRelayBackoff === "function") {
this.nostrClient.clearRelayBackoff(relayUrl);
}
}
recordRelayFailure(relayUrl, error) {
@@ -141,6 +145,12 @@ class RelayHealthService {
state.lastCheckedAt = Date.now();
state.lastErrorAt = Date.now();
if (this.nostrClient && typeof this.nostrClient.markRelayUnreachable === "function") {
this.nostrClient.markRelayUnreachable(relayUrl, 60000, {
reason: "relay-health-failed",
});
}
this.logger.dev.warn("[relayHealth] Relay check failed.", {
relayUrl,
error,
+1 -8
View File
@@ -5,7 +5,7 @@ import {
requestDefaultExtensionPermissions,
} from "./nostrClientFacade.js";
import { convertEventToVideo as sharedConvertEventToVideo } from "./nostr/index.js";
import { normalizeNostrPubkey } from "./nostr/nip46Client.js";
import { normalizeNostrPubkey, sanitizeRelayList } from "./nostr/nip46Client.js";
import {
listVideoViewEvents,
subscribeVideoViewEvents,
@@ -1229,13 +1229,6 @@ class SubscriptionsManager {
throw signErr;
}
const sanitizeRelayList = (candidate) =>
Array.isArray(candidate)
? candidate
.map((url) => (typeof url === "string" ? url.trim() : ""))
.filter(Boolean)
: [];
const writeRelays = sanitizeRelayList(nostrClient.writeRelays);
const fallbackRelays = writeRelays.length
? writeRelays