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 (
+
+
+
+ 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(
-
-
-
- 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