From 9580c5a0c930e95fe028183e00c1d820933996d0 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 13 May 2026 10:22:15 +0200 Subject: [PATCH] more strike system --- src/components/RelayStatusDisplay/index.tsx | 24 +- src/components/SessionRelaysTab/index.tsx | 23 ++ src/lib/publishing-feedback.tsx | 64 +++-- src/lib/relay-strikes.ts | 247 ++++++++++++++++++++ src/services/client-query.service.ts | 20 ++ src/services/client.service.ts | 139 +++++++---- src/services/relay-notice-fetch-failure.ts | 13 +- 7 files changed, 452 insertions(+), 78 deletions(-) create mode 100644 src/lib/relay-strikes.ts diff --git a/src/components/RelayStatusDisplay/index.tsx b/src/components/RelayStatusDisplay/index.tsx index 7735085f..546bcc28 100644 --- a/src/components/RelayStatusDisplay/index.tsx +++ b/src/components/RelayStatusDisplay/index.tsx @@ -1,6 +1,8 @@ import React from 'react' import { Check, X } from 'lucide-react' import { simplifyUrl } from '@/lib/url' +import { Button } from '@/components/ui/button' +import { useTranslation } from 'react-i18next' /** * Format relay error messages to be more user-friendly @@ -105,6 +107,8 @@ interface RelayStatusDisplayProps { * “Published to …” copy (e.g. timeline REQ outcomes). */ aggregateSummary?: React.ReactNode | false + /** When set, failed relays show a control to add the URL to the user’s blocked-relay list. */ + onBlockRelay?: (relayUrl: string) => void } export default function RelayStatusDisplay({ @@ -112,8 +116,10 @@ export default function RelayStatusDisplay({ successCount, totalCount, className = '', - aggregateSummary + aggregateSummary, + onBlockRelay }: RelayStatusDisplayProps) { + const { t } = useTranslation() if (relayStatuses.length === 0) { return null } @@ -164,6 +170,22 @@ export default function RelayStatusDisplay({ {renderTextWithLinks(formatRelayError(status.error))} )} + {!status.success && onBlockRelay && ( +
+ +
+ )} {status.success && status.message && (
{renderTextWithLinks(status.message)} diff --git a/src/components/SessionRelaysTab/index.tsx b/src/components/SessionRelaysTab/index.tsx index a7d59050..b186e0ee 100644 --- a/src/components/SessionRelaysTab/index.tsx +++ b/src/components/SessionRelaysTab/index.tsx @@ -1,5 +1,6 @@ import client from '@/services/client.service' import relayInfoService from '@/services/relay-info.service' +import type { RelayStrikeDebugSnapshot } from '@/lib/relay-strikes' import { isHttpRelayUrl } from '@/lib/url' import { useTranslation } from 'react-i18next' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -11,6 +12,7 @@ import { useNostr } from '@/providers/NostrProvider' type SessionDebug = { scoredRelays: { url: string; successCount: number; avgLatencyMs: number }[] presetWorking: string[] + relayStrikes: RelayStrikeDebugSnapshot } function loadDebug(): SessionDebug { @@ -160,6 +162,27 @@ export default function SessionRelaysTab() { +
+

{t('Session relay strikes', { defaultValue: 'Session relay strikes' })}

+

+ {t('Session relay strikes hint', { + defaultValue: + 'Session-only: failed reads/publishes accrue strikes; five failures skip a relay for three minutes. Rate-limit NOTICEs apply a ten-minute cooldown without strikes. Cache relays (kind 10432) always count failures even during cooldown.' + })} +

+

+ {t('Cache relay keys', { defaultValue: 'Cache relay keys' })}:{' '} + {debug.relayStrikes.cacheRelayKeys.length === 0 + ? t('None') + : debug.relayStrikes.cacheRelayKeys.join(', ')} +

+
+          {debug.relayStrikes.entries.length === 0
+            ? t('None')
+            : JSON.stringify(debug.relayStrikes.entries, null, 2)}
+        
+
+
) } diff --git a/src/lib/publishing-feedback.tsx b/src/lib/publishing-feedback.tsx index e9fa030a..334cf125 100644 --- a/src/lib/publishing-feedback.tsx +++ b/src/lib/publishing-feedback.tsx @@ -1,6 +1,8 @@ import RelayStatusDisplay from '@/components/RelayStatusDisplay' import { CheckCircle2 } from 'lucide-react' import type { ReactNode } from 'react' +import { useContext } from 'react' +import { FavoriteRelaysContext } from '@/providers/favorite-relays-context' import storage from '@/services/local-storage.service' import { toast } from 'sonner' @@ -47,6 +49,42 @@ export type PublishResult = { totalCount: number } +function PublishToastRelayPanel({ + message, + result +}: { + message: string + result: PublishResult +}) { + const fav = useContext(FavoriteRelaysContext) + const onBlockRelay = fav + ? (url: string) => { + void fav.addBlockedRelays([url]) + } + : undefined + + const { relayStatuses, successCount, totalCount } = result + const isSuccess = successCount > 0 + + return ( +
+
+ +
{message}
+
+
+ Published to {successCount} of {totalCount} relays +
+ +
+ ) +} + /** * Show publishing feedback with relay status details * @param result Publishing result with relay statuses @@ -61,7 +99,7 @@ export function showPublishingFeedback( ) { const { message = 'Published successfully', duration = 6000 } = options - const { relayStatuses, successCount, totalCount } = result + const { relayStatuses, successCount } = result if (relayStatuses.length === 0) { // e.g. publishEvent with zero target relays still returns { relayStatuses: [] }; must not use success styling @@ -86,26 +124,10 @@ export function showPublishingFeedback( const toastFunction = isSuccess ? toast.success : toast.error - toastFunction( -
-
- -
{message}
-
-
- Published to {successCount} of {totalCount} relays -
- -
, - { - duration, - className: 'max-w-lg w-full' - } - ) + toastFunction(, { + duration, + className: 'max-w-lg w-full' + }) } /** diff --git a/src/lib/relay-strikes.ts b/src/lib/relay-strikes.ts new file mode 100644 index 00000000..5284b899 --- /dev/null +++ b/src/lib/relay-strikes.ts @@ -0,0 +1,247 @@ +import type { Event } from 'nostr-tools' +import { getRelayListFromEvent } from '@/lib/event-metadata' +import logger from '@/lib/logger' +import { canonicalRelaySessionKey, isHttpRelayUrl } from '@/lib/url' + +/** Conservative: 5 read/publish failures → skip until this many ms after last qualifying failure. */ +const STRIKE_FAILURES_THRESHOLD = 5 +const STRIKE_COOLDOWN_MS = 3 * 60 * 1000 + +/** Rate-limit style NOTICE / overload → cool down without incrementing strike counter. */ +const RATE_LIMIT_COOLDOWN_MS = 10 * 60 * 1000 + +/** Non–cache-relay failures: at most one strike increment per key per this window. */ +const STRIKE_INCREMENT_DEBOUNCE_MS = 30 * 1000 + +export type RelayNoticeClass = 'rate_limit' | 'fetch_failed' | 'neutral' + +const RATE_LIMIT_RE = + /too many concurrent|concurrent req|rate\s*limit|overloaded|429|slow down|throttl|backoff|try again later|maximum\s+subscriptions/i + +const FETCH_FAILED_RE = /failed to fetch events/i + +export function classifyRelayNotice(message: string): RelayNoticeClass { + const m = message.toLowerCase() + if (RATE_LIMIT_RE.test(m)) return 'rate_limit' + if (FETCH_FAILED_RE.test(m)) return 'fetch_failed' + return 'neutral' +} + +type StrikeEntry = { + readFailures: number + readLastStrikeIncrementAt: number + readStrikeSkipUntil: number + publishFailures: number + publishLastStrikeIncrementAt: number + publishStrikeSkipUntil: number + rateLimitUntil: number +} + +export type RelayStrikeDebugSnapshot = { + entries: { key: string; entry: StrikeEntry; cacheRelay: boolean }[] + cacheRelayKeys: string[] +} + +function emptyEntry(): StrikeEntry { + return { + readFailures: 0, + readLastStrikeIncrementAt: 0, + readStrikeSkipUntil: 0, + publishFailures: 0, + publishLastStrikeIncrementAt: 0, + publishStrikeSkipUntil: 0, + rateLimitUntil: 0 + } +} + +function sessionKey(url: string): string { + return canonicalRelaySessionKey(url) +} + +/** + * Session-only relay health: strikes (skip after N failures), rate-limit cooldown (no strike), + * and optional “cache relay” URLs from kind 10432 (always count failures, no debounce). + */ +class RelaySessionStrikes { + private byKey = new Map() + private cacheRelayKeys = new Set() + + setSessionCacheRelayKeysFromKind10432(ev: Event | null | undefined): void { + this.cacheRelayKeys.clear() + if (!ev?.tags?.length) return + const list = getRelayListFromEvent(ev) + const add = (u: string) => { + const k = sessionKey(u) + if (k) this.cacheRelayKeys.add(k) + } + for (const u of list.read) add(u) + for (const u of list.write) add(u) + for (const r of list.originalRelays ?? []) { + if (r.url) add(r.url) + } + for (const u of list.httpRead ?? []) add(u) + for (const u of list.httpWrite ?? []) add(u) + } + + isCacheRelayKeyForUrl(url: string): boolean { + const k = sessionKey(url) + return !!k && this.cacheRelayKeys.has(k) + } + + private getEntry(key: string): StrikeEntry { + let e = this.byKey.get(key) + if (!e) { + e = emptyEntry() + this.byKey.set(key, e) + } + return e + } + + /** True when read / WS / HTTP index fetch should omit this relay (unless single-relay override). */ + isReadHttpSkipped(url: string): boolean { + const key = sessionKey(url) + if (!key) return false + const e = this.byKey.get(key) + if (!e) return false + return Date.now() < Math.max(e.rateLimitUntil, e.readStrikeSkipUntil) + } + + /** True when publish should omit this relay (unless single-target override). */ + isPublishSkipped(url: string): boolean { + const key = sessionKey(url) + if (!key) return false + const e = this.byKey.get(key) + if (!e) return false + return Date.now() < Math.max(e.rateLimitUntil, e.publishStrikeSkipUntil) + } + + handleNotice(relayKeyRaw: string, message: string): void { + const key = sessionKey(relayKeyRaw) + if (!key) return + const kind = classifyRelayNotice(message) + if (kind === 'rate_limit') { + this.applyRateLimitCooldownKey(key) + return + } + if (kind === 'fetch_failed') { + this.recordReadFailureKey(key, 'notice') + } + } + + applyRateLimitCooldownForUrl(url: string): void { + const key = sessionKey(url) + if (key) this.applyRateLimitCooldownKey(key) + } + + private applyRateLimitCooldownKey(key: string): void { + const now = Date.now() + const e = this.getEntry(key) + e.rateLimitUntil = Math.max(e.rateLimitUntil, now + RATE_LIMIT_COOLDOWN_MS) + logger.debug('[RelayStrikes] rate-limit cooldown', { + key, + untilMs: e.rateLimitUntil - now + }) + } + + /** WS connect failure, HTTP transport failure, etc. */ + recordReadFailure(url: string, _source: 'connection' | 'notice' | 'http'): void { + const key = sessionKey(url) + if (!key) return + this.recordReadFailureKey(key, _source) + } + + private recordReadFailureKey(key: string, _source: 'connection' | 'notice' | 'http'): void { + const now = Date.now() + const e = this.getEntry(key) + // During rate-limit cooldown, do not add strikes for normal relays (relay can catch up). + // Cache relays from kind 10432 (e.g. localhost on another machine) always accrue failures. + if (now < e.rateLimitUntil && !this.cacheRelayKeys.has(key)) return + + const cache = this.cacheRelayKeys.has(key) + if (!cache) { + if (now - e.readLastStrikeIncrementAt < STRIKE_INCREMENT_DEBOUNCE_MS) return + e.readLastStrikeIncrementAt = now + } + + e.readFailures += 1 + if (e.readFailures >= STRIKE_FAILURES_THRESHOLD) { + e.readStrikeSkipUntil = Math.max(e.readStrikeSkipUntil, now + STRIKE_COOLDOWN_MS) + logger.info('[RelayStrikes] read path strike skip', { key, readFailures: e.readFailures }) + } else { + logger.debug('[RelayStrikes] read failure counted', { key, readFailures: e.readFailures, cache }) + } + } + + recordReadSuccess(url: string): void { + const key = sessionKey(url) + if (!key) return + const e = this.byKey.get(key) + if (!e) return + e.readFailures = 0 + e.readStrikeSkipUntil = 0 + e.readLastStrikeIncrementAt = 0 + } + + recordPublishFailure(url: string): void { + const key = sessionKey(url) + if (!key) return + const now = Date.now() + const e = this.getEntry(key) + if (now < e.rateLimitUntil && !this.cacheRelayKeys.has(key)) return + const cache = this.cacheRelayKeys.has(key) + if (!cache) { + if (now - e.publishLastStrikeIncrementAt < STRIKE_INCREMENT_DEBOUNCE_MS) return + e.publishLastStrikeIncrementAt = now + } + e.publishFailures += 1 + if (e.publishFailures >= STRIKE_FAILURES_THRESHOLD) { + e.publishStrikeSkipUntil = Math.max(e.publishStrikeSkipUntil, now + STRIKE_COOLDOWN_MS) + logger.info('[RelayStrikes] publish path strike skip', { key, publishFailures: e.publishFailures }) + } + } + + /** Successful publish clears publish strikes (existing publish stats stay in ClientService). */ + recordPublishSuccess(url: string): void { + const key = sessionKey(url) + if (!key) return + const e = this.byKey.get(key) + if (!e) return + e.publishFailures = 0 + e.publishStrikeSkipUntil = 0 + e.publishLastStrikeIncrementAt = 0 + } + + filterPublishUrls(urls: readonly string[]): string[] { + if (urls.length <= 1) return [...urls] + const out = urls.filter((u) => !this.isPublishSkipped(u)) + return out.length > 0 ? out : [...urls] + } + + filterReadHttpUrls(urls: readonly string[]): string[] { + const ws = urls.filter((u) => !isHttpRelayUrl(u)) + const http = urls.filter((u) => isHttpRelayUrl(u)) + const singleWsRelay = ws.length <= 1 + const wsOut = singleWsRelay ? [...ws] : ws.filter((u) => !this.isReadHttpSkipped(u)) + const httpOut = http.filter((u) => !this.isReadHttpSkipped(u)) + const merged = [...wsOut, ...httpOut] + return merged.length > 0 ? merged : [...urls] + } + + getDebugSnapshot(): RelayStrikeDebugSnapshot { + return { + entries: Array.from(this.byKey.entries()).map(([key, entry]) => ({ + key, + entry: { ...entry }, + cacheRelay: this.cacheRelayKeys.has(key) + })), + cacheRelayKeys: Array.from(this.cacheRelayKeys) + } + } + + reset(): void { + this.byKey.clear() + this.cacheRelayKeys.clear() + } +} + +export const relaySessionStrikes = new RelaySessionStrikes() diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 454f4209..2a01d5c0 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -17,6 +17,7 @@ import { relayUrlsStripExtendedTagReqBlocked } from '@/lib/relay-extended-tag-req-blocks' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' +import { relaySessionStrikes } from '@/lib/relay-strikes' import { queueRelayAuthSign } from '@/lib/relay-auth-sign-queue' import { authenticateNip42Relay, @@ -382,8 +383,10 @@ export class QueryService { firstResultTime = Date.now() } } + relaySessionStrikes.recordReadSuccess(base) } catch (e) { if ((e as Error).name === 'AbortError') return + relaySessionStrikes.recordReadFailure(base, 'http') if (isIndexRelayTransportFailure(e)) { logger.debug('[QueryService] HTTP index relay unreachable', { base, error: e }) } else { @@ -600,6 +603,11 @@ export class QueryService { } relays = relays.filter((url) => !isHttpRelayUrl(url)) + const wsCountBeforeStrikes = relays.length + if (wsCountBeforeStrikes > 1) { + relays = relaySessionStrikes.filterReadHttpUrls(relays) + } + if (relays.length === 0) { queueMicrotask(() => callbacks.oneose?.(true)) return { close: () => {} } @@ -624,6 +632,14 @@ export class QueryService { return { url, filters: filtersForRelay } }) + if (groupedRequests.length === 1) { + try { + this.pool.close([groupedRequests[0]!.url]) + } catch { + /* ignore */ + } + } + const opSource = relayOpMeta?.source ?? 'QueryService.subscribe' const opBatch = groupedRequests.length > 0 @@ -637,6 +653,7 @@ export class QueryService { if (eosesReceived[i]) return eosesReceived[i] = true opBatch?.setTerminal(i, 'eose') + relaySessionStrikes.recordReadSuccess(groupedRequests[i]!.url) if (eosesReceived.filter(Boolean).length === groupedRequests.length) { callbacks.oneose?.(true) } @@ -684,6 +701,7 @@ export class QueryService { }) patchRelayNoticeForFetchFailures(relay, relayKey, this.onRelayNoticeFetchFailure) } catch (err) { + relaySessionStrikes.recordReadFailure(url, 'connection') this.releaseSubSlot(relayKey) handleClose(i, (err as Error)?.message ?? String(err)) return @@ -730,6 +748,7 @@ export class QueryService { }) patchRelayNoticeForFetchFailures(liveRelay, relayKey, this.onRelayNoticeFetchFailure) } catch (err) { + relaySessionStrikes.recordReadFailure(url, 'connection') nip42ResubscribePending.delete(i) this.releaseSubSlot(relayKey) handleClose(i, (err as Error)?.message ?? String(err)) @@ -763,6 +782,7 @@ export class QueryService { }) nip42ResubscribePending.delete(i) } catch (err) { + relaySessionStrikes.recordReadFailure(url, 'connection') nip42ResubscribePending.delete(i) releaseSlot2() handleClose(i, (err as Error)?.message ?? String(err)) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 23205a3e..30e741ea 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -140,6 +140,7 @@ import { } from '@/lib/url' import { canonicalFeedFilter, canonicalRelayUrls } from '@/features/feed/descriptor' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' +import { relaySessionStrikes } from '@/lib/relay-strikes' import { isSafari } from '@/lib/utils' import { ISigner, @@ -333,7 +334,7 @@ class ClientService extends EventTarget { // Initialize sub-services this.queryService = new QueryService(this.pool, { onRelayNoticeFetchFailure: (normalizedUrl, noticeMessage) => - this.logRelayNoticeFetchFailure(normalizedUrl, noticeMessage) + this.handleRelayNoticeSession(normalizedUrl, noticeMessage) }) this.eventService = new EventService(this.queryService) this.replaceableEventService = new ReplaceableEventService( @@ -1183,19 +1184,23 @@ class ClientService extends EventTarget { return relays } - /** NOTICE "failed to fetch events" — logged only (no session relay blocking). */ - private logRelayNoticeFetchFailure(url: string, noticeMessage: string) { - const n = canonicalRelaySessionKey(url) - logger.debug('[Relay] NOTICE failed-fetch', { - url: n ?? url, - noticeSnippet: noticeMessage.slice(0, 220) - }) + /** NOTICE handler: session strikes + rate-limit cooldown + debug log for fetch failures. */ + private handleRelayNoticeSession(relayKey: string, noticeMessage: string) { + relaySessionStrikes.handleNotice(relayKey, noticeMessage) + if (/failed to fetch events/i.test(noticeMessage)) { + const n = canonicalRelaySessionKey(relayKey) + logger.debug('[Relay] NOTICE failed-fetch', { + url: n ?? relayKey, + noticeSnippet: noticeMessage.slice(0, 220) + }) + } } /** Record a successful publish and its latency for session-based preference when selecting random relays. */ recordPublishSuccess(url: string, latencyMs: number) { const n = canonicalRelaySessionKey(url) if (!n) return + relaySessionStrikes.recordPublishSuccess(url) const cur = this.sessionRelayPublishStats.get(n) if (cur) { cur.successCount += 1 @@ -1231,6 +1236,7 @@ class ClientService extends EventTarget { getSessionRelayDebug(): { scoredRelays: { url: string; successCount: number; avgLatencyMs: number }[] presetWorking: string[] + relayStrikes: ReturnType } { const presetSet = new Set() for (const u of [ @@ -1249,7 +1255,7 @@ class ClientService extends EventTarget { avgLatencyMs: Math.round(s.sumLatencyMs / s.successCount) })) scoredRelays.sort((a, b) => a.avgLatencyMs - b.avgLatencyMs) - return { scoredRelays, presetWorking: preset } + return { scoredRelays, presetWorking: preset, relayStrikes: relaySessionStrikes.getDebugSnapshot() } } /** @@ -1324,13 +1330,22 @@ class ClientService extends EventTarget { ) const uniqueRelayUrls = filtered + const publishTargetUrls = relaySessionStrikes.filterPublishUrls(uniqueRelayUrls) + /** Single-relay publish: force a fresh socket so explorer / one-relay flows still try hard. */ + if (publishTargetUrls.length === 1) { + try { + this.pool.close(publishTargetUrls) + } catch { + /* ignore */ + } + } /** Single-relay: full NIP-42 ACK budget; multi-relay: avoid waiting on the slowest peer for “all settled”. */ const publishAckBudgetCapMs = - uniqueRelayUrls.length <= 1 + publishTargetUrls.length <= 1 ? RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS : Math.min(RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS, MULTI_RELAY_PUBLISH_ACK_CAP_MS) - if (relayUrls.length !== uniqueRelayUrls.length || mergedRelayUrls.length !== uniqueRelayUrls.length) { + if (relayUrls.length !== publishTargetUrls.length || mergedRelayUrls.length !== publishTargetUrls.length) { logger.info('[PublishEvent] Publish target relays (UI selection vs actually contacted)', { eventId: event.id?.substring(0, 12), kind: event.kind, @@ -1338,8 +1353,9 @@ class ClientService extends EventTarget { fromPickerOrDetermineCount: relayUrls.length, afterMergeWithYourOutboxes: mergedRelayUrls.length, afterReadonlySocialFilter: countAfterFiltersBeforeCap, - finalContactedRelayCount: uniqueRelayUrls.length, - finalRelays: uniqueRelayUrls, + afterStrikeFilter: publishTargetUrls.length, + finalContactedRelayCount: publishTargetUrls.length, + finalRelays: publishTargetUrls, explain: 'Your NIP-65 write relays are prepended, then the list is de-duplicated, filtered (read-only / social-kind blocks), and capped at maxPublishRelays in outbox→inbox→favorite→fast-write priority. Unchecked relays in the picker are never contacted; checked relays beyond the cap or filtered out are also skipped.' }) @@ -1348,10 +1364,10 @@ class ClientService extends EventTarget { logger.debug('[PublishEvent] Starting publishEvent', { eventId: event.id?.substring(0, 8), kind: event.kind, - relayCount: uniqueRelayUrls.length, + relayCount: publishTargetUrls.length, relayUrlsPassedInCount: relayUrls.length }) - if (uniqueRelayUrls.length === 0) { + if (publishTargetUrls.length === 0) { const emptyBatch = new RelayPublishOpBatch('ClientService.publishEvent', event.id, []) emptyBatch.logBegin() emptyBatch.logEnd('no_targets') @@ -1371,11 +1387,11 @@ class ClientService extends EventTarget { logger.info('[PublishEvent] Publishing event to relays', { eventId: event.id?.substring(0, 8), kind: event.kind, - totalRelayCount: uniqueRelayUrls.length, - allRelays: uniqueRelayUrls + totalRelayCount: publishTargetUrls.length, + allRelays: publishTargetUrls }) } else { - logger.debug('[PublishEvent] Unique relays', { count: uniqueRelayUrls.length, relays: uniqueRelayUrls.slice(0, 5) }) + logger.debug('[PublishEvent] Unique relays', { count: publishTargetUrls.length, relays: publishTargetUrls.slice(0, 5) }) } const publishBatchSource = publishExtras?.publishBatchLabel @@ -1385,13 +1401,13 @@ class ClientService extends EventTarget { const idBit = event.id && /^[0-9a-f]{64}$/i.test(event.id) ? `${event.id.slice(0, 12)}…` : '(unsigned or no id)' logger.info(`[Publish] ${publishExtras.publishBatchLabel}`, { - readable: `Kind ${event.kind} note ${idBit} → ${uniqueRelayUrls.length} relay(s): ${uniqueRelayUrls.map(relayHostForUserLog).join(', ')}`, - targets: uniqueRelayUrls.map((url) => ({ where: relayHostForUserLog(url), url })) + readable: `Kind ${event.kind} note ${idBit} → ${publishTargetUrls.length} relay(s): ${publishTargetUrls.map(relayHostForUserLog).join(', ')}`, + targets: publishTargetUrls.map((url) => ({ where: relayHostForUserLog(url), url })) }) } const relayStatuses: { url: string; success: boolean; error?: string }[] = [] - const publishOpBatch = new RelayPublishOpBatch(publishBatchSource, event.id, uniqueRelayUrls) + const publishOpBatch = new RelayPublishOpBatch(publishBatchSource, event.id, publishTargetUrls) publishOpBatch.logBegin() // eslint-disable-next-line @typescript-eslint/no-this-alias @@ -1404,7 +1420,7 @@ class ClientService extends EventTarget { const flushPublishOpBatch = (status: string) => { if (publishOpBatchFlushed) return publishOpBatchFlushed = true - uniqueRelayUrls.forEach((url, idx) => { + publishTargetUrls.forEach((url, idx) => { const rs = [...relayStatuses].reverse().find((r) => r.url === url) publishOpBatch.record(idx, url, rs?.success === true, rs?.error) }) @@ -1418,7 +1434,7 @@ class ClientService extends EventTarget { * by {@link MAX_PUBLISH_RELAYS}. Budget still scales with relay count as a rough upper bound. */ const slotCap = Math.max(1, MAX_CONCURRENT_RELAY_CONNECTIONS) - const publishWaves = Math.max(1, Math.ceil(uniqueRelayUrls.length / slotCap)) + const publishWaves = Math.max(1, Math.ceil(publishTargetUrls.length / slotCap)) const perWaveBudgetMs = RELAY_POOL_CONNECTION_TIMEOUT_MS + publishAckBudgetCapMs + 10_000 const publishGlobalDeadlineMs = Math.min( @@ -1431,7 +1447,7 @@ class ClientService extends EventTarget { logger.debug('[PublishEvent] Setting up global timeout', { publishGlobalDeadlineMs, publishWaves, - relayCount: uniqueRelayUrls.length, + relayCount: publishTargetUrls.length, slotCap }) let hasResolved = false @@ -1456,17 +1472,18 @@ class ClientService extends EventTarget { logger.warn('[PublishEvent] Global timeout reached!', { finishedCount, - totalRelays: uniqueRelayUrls.length, + totalRelays: publishTargetUrls.length, successCount, relayStatusesCount: relayStatuses.length }) // Mark any unfinished relays as failed - uniqueRelayUrls.forEach(url => { + publishTargetUrls.forEach(url => { const alreadyFinished = relayStatuses.some(rs => rs.url === url) if (!alreadyFinished) { logger.warn('[PublishEvent] Marking relay as timed out', { url }) relayStatuses.push({ url, success: false, error: 'Timeout: Operation took too long' }) + relaySessionStrikes.recordPublishFailure(url) finishedCount++ } }) @@ -1480,28 +1497,28 @@ class ClientService extends EventTarget { hasResolved = true maybeEmitNewEventForLiveFeeds() logger.debug('[PublishEvent] Resolving due to timeout', { - success: successCount >= uniqueRelayUrls.length / 3, + success: successCount >= publishTargetUrls.length / 3, successCount, - totalCount: uniqueRelayUrls.length, + totalCount: publishTargetUrls.length, relayStatuses: relayStatuses.length }) flushPublishOpBatch('global_timeout') resolve({ - success: successCount >= uniqueRelayUrls.length / 3, + success: successCount >= publishTargetUrls.length / 3, relayStatuses, successCount, - totalCount: uniqueRelayUrls.length + totalCount: publishTargetUrls.length }) } }, publishGlobalDeadlineMs) logger.debug('[PublishEvent] Starting Promise.allSettled for all relays') const relayPublishAllSettled = Promise.allSettled( - uniqueRelayUrls.map(async (url, index) => { + publishTargetUrls.map(async (url, index) => { // eslint-disable-next-line @typescript-eslint/no-this-alias const that = this const startMs = Date.now() - logger.debug(`[PublishEvent] Starting relay ${index + 1}/${uniqueRelayUrls.length}`, { url }) + logger.debug(`[PublishEvent] Starting relay ${index + 1}/${publishTargetUrls.length}`, { url }) const isLocal = isLocalNetworkUrl(url) /** Match pool handshake budget; a shorter outer race used to abort `ensureRelay` at 8s while the pool allowed 20s — slow TLS never won. */ const connectionTimeout = isLocal ? 5_000 : RELAY_POOL_CONNECTION_TIMEOUT_MS @@ -1566,7 +1583,7 @@ class ClientService extends EventTarget { logger.debug(`[PublishEvent] Relay connected`, { url }) const relayKeyPub = normalizeUrl(url) || url patchRelayNoticeForFetchFailures(relay as unknown as AbstractRelay, relayKeyPub, (u, m) => - that.logRelayNoticeFetchFailure(u, m) + that.handleRelayNoticeSession(u, m) ) applyRelayNip42AckTimeout(relay as unknown as AbstractRelay) @@ -1612,11 +1629,13 @@ class ClientService extends EventTarget { logger.error(`[PublishEvent] Auth or publish failed`, { url, error: authError.message }) errors.push({ url, error: authError }) relayStatuses.push({ url, success: false, error: authError.message }) + relaySessionStrikes.recordPublishFailure(url) }) } else { logger.error(`[PublishEvent] Publish failed`, { url, error: error.message }) errors.push({ url, error }) relayStatuses.push({ url, success: false, error: error.message }) + relaySessionStrikes.recordPublishFailure(url) } }) @@ -1674,36 +1693,37 @@ class ClientService extends EventTarget { success: false, error: error instanceof Error ? error.message : 'Connection failed' }) + relaySessionStrikes.recordPublishFailure(url) } finally { clearTimeout(relayTimeout) const currentFinished = ++finishedCount logger.debug(`[PublishEvent] Relay finished`, { url, finishedCount: currentFinished, - totalRelays: uniqueRelayUrls.length, + totalRelays: publishTargetUrls.length, successCount }) maybeEmitNewEventForLiveFeeds() - if (currentFinished >= uniqueRelayUrls.length && !hasResolved) { + if (currentFinished >= publishTargetUrls.length && !hasResolved) { if (earlyGraceTimer != null) { clearTimeout(earlyGraceTimer) earlyGraceTimer = null } hasResolved = true logger.debug('[PublishEvent] All relays finished, resolving', { - success: successCount >= uniqueRelayUrls.length / 3, + success: successCount >= publishTargetUrls.length / 3, successCount, - totalCount: uniqueRelayUrls.length, + totalCount: publishTargetUrls.length, relayStatusesCount: relayStatuses.length }) clearTimeout(globalTimeout) flushPublishOpBatch('all_relays_finished') resolve({ - success: successCount >= uniqueRelayUrls.length / 3, + success: successCount >= publishTargetUrls.length / 3, relayStatuses, successCount, - totalCount: uniqueRelayUrls.length + totalCount: publishTargetUrls.length }) } else if (!hasResolved && successCount >= 1 && earlyGraceTimer == null) { earlyGraceTimer = setTimeout(() => { @@ -1713,17 +1733,17 @@ class ClientService extends EventTarget { clearTimeout(globalTimeout) flushPublishOpBatch('early_any_success_grace') logger.debug('[PublishEvent] Resolving after first success grace', { - success: successCount >= uniqueRelayUrls.length / 3, + success: successCount >= publishTargetUrls.length / 3, successCount, - totalCount: uniqueRelayUrls.length, + totalCount: publishTargetUrls.length, finishedRelays: currentFinished, graceMs: EARLY_PUBLISH_SUCCESS_GRACE_MS }) resolve({ - success: successCount >= uniqueRelayUrls.length / 3, + success: successCount >= publishTargetUrls.length / 3, relayStatuses, successCount, - totalCount: uniqueRelayUrls.length + totalCount: publishTargetUrls.length }) }, EARLY_PUBLISH_SUCCESS_GRACE_MS) } @@ -2219,6 +2239,11 @@ class ClientService extends EventTarget { } relays = Array.from(new Set(relays)) + const wsRelayCountBeforeStrikes = relays.length + if (wsRelayCountBeforeStrikes > 1) { + relays = relaySessionStrikes.filterReadHttpUrls(relays) + } + // eslint-disable-next-line @typescript-eslint/no-this-alias const that = this const _knownIds = new Set() @@ -2240,6 +2265,14 @@ class ClientService extends EventTarget { return { url, filters: filtersForRelay } }) + if (groupedRequests.length === 1) { + try { + this.pool.close([groupedRequests[0]!.url]) + } catch { + /* ignore */ + } + } + // Social-kind queries drop SOCIAL_KIND_BLOCKED_RELAY_URLS; if every URL was removed, no subs run and // oneose would never fire — timelines stay loading forever (e.g. favorites feed). if (groupedRequests.length === 0) { @@ -2298,6 +2331,7 @@ class ClientService extends EventTarget { eosesReceived[i] = true opBatch.setTerminal(i, 'eose') logFirstRelayResponse('eose', groupedRequests[i]!.url) + relaySessionStrikes.recordReadSuccess(groupedRequests[i]!.url) if (eosesReceived.filter(Boolean).length === groupedRequests.length) { oneose?.(true) } @@ -2341,10 +2375,9 @@ class ClientService extends EventTarget { let relay: AbstractRelay try { relay = await that.pool.ensureRelay(url, { connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS }) - patchRelayNoticeForFetchFailures(relay, relayKey, (u, m) => - that.logRelayNoticeFetchFailure(u, m) - ) + patchRelayNoticeForFetchFailures(relay, relayKey, (u, m) => that.handleRelayNoticeSession(u, m)) } catch (err) { + relaySessionStrikes.recordReadFailure(url, 'connection') that.queryService.releaseSubSlot(relayKey) handleClose(i, (err as Error)?.message ?? String(err)) return @@ -2396,9 +2429,10 @@ class ClientService extends EventTarget { connectionTimeout: RELAY_POOL_CONNECTION_TIMEOUT_MS }) patchRelayNoticeForFetchFailures(liveRelay, relayKey, (u, m) => - that.logRelayNoticeFetchFailure(u, m) + that.handleRelayNoticeSession(u, m) ) } catch (err) { + relaySessionStrikes.recordReadFailure(url, 'connection') nip42ResubscribePending.delete(i) that.queryService.releaseSubSlot(relayKey) handleClose(i, (err as Error)?.message ?? String(err)) @@ -3808,7 +3842,7 @@ class ClientService extends EventTarget { storedHttpRelayEvents: (NEvent | null | undefined)[], storedCacheRelayEvents: (NEvent | null | undefined)[] ): TRelayList[] { - return pubkeys.map((targetPubkey, index) => { + const mergedLists = pubkeys.map((targetPubkey, index) => { const isOwnRelayList = this.pubkey != null && hexPubkeysEqual(this.pubkey, userIdToPubkey(targetPubkey)) @@ -3904,6 +3938,15 @@ class ClientService extends EventTarget { // were stripped above; strip again after HTTP merge for other users' bundles only (viewer keeps 10432/LAN). return isOwnRelayList ? merged : stripLocalNetworkRelaysFromRelayList(merged) }) + if (this.pubkey) { + const i = pubkeys.findIndex((pk) => hexPubkeysEqual(this.pubkey!, userIdToPubkey(pk))) + if (i >= 0) { + const storedCacheEvent = storedCacheRelayEvents[i] + const cacheResolved = cacheRelayEvents[i] || storedCacheEvent + relaySessionStrikes.setSessionCacheRelayKeysFromKind10432(cacheResolved ?? null) + } + } + return mergedLists } /** Background refresh so UI/publish can use IDB immediately while relays catch up. */ diff --git a/src/services/relay-notice-fetch-failure.ts b/src/services/relay-notice-fetch-failure.ts index 4550c653..37513b56 100644 --- a/src/services/relay-notice-fetch-failure.ts +++ b/src/services/relay-notice-fetch-failure.ts @@ -2,25 +2,22 @@ import type { AbstractRelay } from 'nostr-tools/abstract-relay' const patched = new WeakSet() -/** NOTICE bodies that indicate the relay backend failed to serve the REQ. */ -const FAILED_FETCH_EVENTS = /failed to fetch events/i - /** - * One-time patch: relay NOTICE "failed to fetch events" -> diagnostic callback. + * One-time patch: forward every relay NOTICE to the app (strikes / rate-limit cooldown / logs). * Safe to call on every ensureRelay; only the first patch per relay instance applies. */ export function patchRelayNoticeForFetchFailures( relay: AbstractRelay, relayKey: string, - onFailure?: (normalizedUrl: string, noticeMessage: string) => void + onNotice?: (relayKey: string, noticeMessage: string) => void ): void { - if (!onFailure || patched.has(relay as object)) return + if (!onNotice || patched.has(relay as object)) return patched.add(relay as object) const previous = relay.onnotice.bind(relay) relay.onnotice = (msg: string) => { - if (typeof msg === 'string' && FAILED_FETCH_EVENTS.test(msg)) { + if (typeof msg === 'string' && msg.trim()) { try { - onFailure(relayKey, msg) + onNotice(relayKey, msg) } catch { /* ignore */ }