Merge pull request #1983 from PR0M3TH3AN/codex/improve-websocket-stability-and-ui-responsiveness

Add per-relay backoff and rate-limited relay summary logging
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-30 11:01:30 -05:00
commit 9c8cc465ce
1 file changed
+85 -20
+85 -20
View File
@@ -413,10 +413,13 @@ const RELAY_CONNECT_TIMEOUT_MS = 5000;
const RELAY_RECONNECT_BASE_DELAY_MS = 2000;
const RELAY_RECONNECT_MAX_DELAY_MS = 60000;
const RELAY_RECONNECT_MAX_ATTEMPTS = 5;
const RELAY_BACKOFF_BASE_DELAY_MS = 1000;
const RELAY_BACKOFF_MAX_DELAY_MS = 8000;
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 RELAY_SUMMARY_LOG_INTERVAL_MS = 30000;
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.
@@ -1298,6 +1301,7 @@ export class NostrClient {
this.relayFailureCounts = new Map();
this.relayFailureWindows = new Map();
this.relayCircuitBreakers = new Map();
this.relaySummaryLogTimestamps = new Map();
this.relayReconnectTimer = null;
this.relayReconnectAttempt = 0;
this.isInitialized = false;
@@ -4558,16 +4562,41 @@ export class NostrClient {
}, delayMs);
}
logRelaySummary({
key,
level = "warn",
message,
payload,
} = {}) {
if (!key || !message) {
return;
}
const now = Date.now();
const lastLogged = this.relaySummaryLogTimestamps.get(key) || 0;
if (now - lastLogged < RELAY_SUMMARY_LOG_INTERVAL_MS) {
return;
}
this.relaySummaryLogTimestamps.set(key, now);
const channel = isDevMode ? devLogger : userLogger;
const logFn =
typeof channel[level] === "function" ? channel[level] : channel.warn;
if (payload) {
logFn(`[nostr] ${message}`, payload);
} else {
logFn(`[nostr] ${message}`);
}
}
resolveRelayBackoffMs(failureCount, ttlOverride) {
const baseMs = 15000;
const maxMs = 5 * 60 * 1000;
const baseMs = RELAY_BACKOFF_BASE_DELAY_MS;
const maxMs = RELAY_BACKOFF_MAX_DELAY_MS;
const computed = Math.min(
maxMs,
baseMs * Math.pow(2, Math.max(0, failureCount - 1))
);
const override = Number(ttlOverride);
if (Number.isFinite(override) && override > 0) {
return Math.max(computed, Math.floor(override));
return Math.min(computed, Math.floor(override));
}
return computed;
}
@@ -4580,11 +4609,24 @@ export class NostrClient {
if (!normalized) {
return;
}
const hadBackoffState =
this.relayBackoff.has(normalized) ||
this.unreachableRelays.has(normalized) ||
this.relayFailureCounts.has(normalized) ||
this.relayCircuitBreakers.has(normalized);
this.relayBackoff.delete(normalized);
this.relayFailureCounts.delete(normalized);
this.relayFailureWindows.delete(normalized);
this.unreachableRelays.delete(normalized);
this.relayCircuitBreakers.delete(normalized);
if (hadBackoffState) {
this.logRelaySummary({
key: `relay-recovered:${normalized}`,
level: "info",
message: "Relay recovered; backoff cleared.",
payload: { relay: normalized },
});
}
}
recordRelayFailureWindow(url) {
@@ -4626,7 +4668,10 @@ export class NostrClient {
reason,
});
this.unreachableRelays.add(normalized);
if (windowFailureCount >= RELAY_FAILURE_WINDOW_THRESHOLD) {
const shouldOpenCircuit =
nextFailureCount >= RELAY_CIRCUIT_BREAKER_THRESHOLD ||
windowFailureCount >= RELAY_FAILURE_WINDOW_THRESHOLD;
if (shouldOpenCircuit) {
const now = Date.now();
const openUntil = now + RELAY_CIRCUIT_BREAKER_COOLDOWN_MS;
const existing = this.relayCircuitBreakers.get(normalized);
@@ -4634,26 +4679,40 @@ export class NostrClient {
existing && Number.isFinite(existing.openUntil)
? Math.max(existing.openUntil, openUntil)
: openUntil;
const circuitReason = reason || "windowed-failures";
const circuitReason =
reason ||
(nextFailureCount >= RELAY_CIRCUIT_BREAKER_THRESHOLD
? "consecutive-failures"
: "windowed-failures");
this.relayCircuitBreakers.set(normalized, {
openUntil: nextOpenUntil,
failureCount: nextFailureCount,
reason: circuitReason,
});
if (isDevMode) {
devLogger.debug("[nostr] Circuit breaker opened for relay.", {
this.logRelaySummary({
key: `relay-circuit:${normalized}`,
level: "warn",
message: "Circuit breaker opened for relay.",
payload: {
relay: normalized,
openUntil: nextOpenUntil,
failureCount: nextFailureCount,
reason: circuitReason,
});
}
}
if (isDevMode) {
devLogger.debug(
`[nostr] Relay backoff set for ${normalized} (${backoffMs}ms).`,
);
},
});
}
this.logRelaySummary({
key: `relay-backoff:${normalized}`,
level: "warn",
message: "Relay backoff applied.",
payload: {
relay: normalized,
backoffMs,
failureCount: nextFailureCount,
retryAt,
reason,
},
});
}
getHealthyRelays(candidates) {
@@ -4673,9 +4732,12 @@ export class NostrClient {
this.relayFailureWindows.delete(url);
this.unreachableRelays.delete(url);
this.relayBackoff.delete(url);
if (isDevMode) {
devLogger.debug(`[nostr] Circuit breaker reset for ${url}.`);
}
this.logRelaySummary({
key: `relay-circuit-reset:${url}`,
level: "info",
message: "Circuit breaker reset for relay.",
payload: { relay: url },
});
}
if (!this.unreachableRelays.has(url)) {
return true;
@@ -4691,9 +4753,12 @@ export class NostrClient {
this.relayFailureCounts.delete(url);
this.relayFailureWindows.delete(url);
this.unreachableRelays.delete(url);
if (isDevMode) {
devLogger.debug(`[nostr] Relay backoff expired for ${url}.`);
}
this.logRelaySummary({
key: `relay-backoff-expired:${url}`,
level: "info",
message: "Relay backoff expired.",
payload: { relay: url },
});
return true;
});
}