Browse Source

more strike system

imwald
Silberengel 1 month ago
parent
commit
9580c5a0c9
  1. 24
      src/components/RelayStatusDisplay/index.tsx
  2. 23
      src/components/SessionRelaysTab/index.tsx
  3. 64
      src/lib/publishing-feedback.tsx
  4. 247
      src/lib/relay-strikes.ts
  5. 20
      src/services/client-query.service.ts
  6. 139
      src/services/client.service.ts
  7. 13
      src/services/relay-notice-fetch-failure.ts

24
src/components/RelayStatusDisplay/index.tsx

@ -1,6 +1,8 @@ @@ -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 { @@ -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({ @@ -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({ @@ -164,6 +170,22 @@ export default function RelayStatusDisplay({
{renderTextWithLinks(formatRelayError(status.error))}
</div>
)}
{!status.success && onBlockRelay && (
<div className="pt-1">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={(e) => {
e.stopPropagation()
onBlockRelay(status.url)
}}
>
{t('Block relay', { defaultValue: 'Block relay' })}
</Button>
</div>
)}
{status.success && status.message && (
<div className="text-xs text-green-600 dark:text-green-400 break-words">
{renderTextWithLinks(status.message)}

23
src/components/SessionRelaysTab/index.tsx

@ -1,5 +1,6 @@ @@ -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' @@ -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() { @@ -160,6 +162,27 @@ export default function SessionRelaysTab() {
</ul>
</section>
<section className="space-y-2">
<h3 className="text-sm font-medium">{t('Session relay strikes', { defaultValue: 'Session relay strikes' })}</h3>
<p className="text-muted-foreground text-xs">
{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.'
})}
</p>
<p className="text-xs text-muted-foreground font-mono break-all">
{t('Cache relay keys', { defaultValue: 'Cache relay keys' })}:{' '}
{debug.relayStrikes.cacheRelayKeys.length === 0
? t('None')
: debug.relayStrikes.cacheRelayKeys.join(', ')}
</p>
<pre className="rounded-lg border bg-muted/30 p-3 text-[11px] font-mono overflow-x-auto max-h-48 overflow-y-auto">
{debug.relayStrikes.entries.length === 0
? t('None')
: JSON.stringify(debug.relayStrikes.entries, null, 2)}
</pre>
</section>
</div>
)
}

64
src/lib/publishing-feedback.tsx

@ -1,6 +1,8 @@ @@ -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 = { @@ -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 (
<div className="w-full min-w-0">
<div className="flex items-center gap-2 mb-3">
<CheckCircle2 className={`w-5 h-5 ${isSuccess ? 'text-green-500' : 'text-red-500'}`} />
<div className="font-semibold">{message}</div>
</div>
<div className="text-xs text-muted-foreground mb-2">
Published to {successCount} of {totalCount} relays
</div>
<RelayStatusDisplay
relayStatuses={relayStatuses}
successCount={successCount}
totalCount={totalCount}
onBlockRelay={onBlockRelay}
/>
</div>
)
}
/**
* Show publishing feedback with relay status details
* @param result Publishing result with relay statuses
@ -61,7 +99,7 @@ export function showPublishingFeedback( @@ -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( @@ -86,26 +124,10 @@ export function showPublishingFeedback(
const toastFunction = isSuccess ? toast.success : toast.error
toastFunction(
<div className="w-full min-w-0">
<div className="flex items-center gap-2 mb-3">
<CheckCircle2 className={`w-5 h-5 ${isSuccess ? 'text-green-500' : 'text-red-500'}`} />
<div className="font-semibold">{message}</div>
</div>
<div className="text-xs text-muted-foreground mb-2">
Published to {successCount} of {totalCount} relays
</div>
<RelayStatusDisplay
relayStatuses={relayStatuses}
successCount={successCount}
totalCount={totalCount}
/>
</div>,
{
duration,
className: 'max-w-lg w-full'
}
)
toastFunction(<PublishToastRelayPanel message={message} result={result} />, {
duration,
className: 'max-w-lg w-full'
})
}
/**

247
src/lib/relay-strikes.ts

@ -0,0 +1,247 @@ @@ -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<string, StrikeEntry>()
private cacheRelayKeys = new Set<string>()
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()

20
src/services/client-query.service.ts

@ -17,6 +17,7 @@ import { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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))

139
src/services/client.service.ts

@ -140,6 +140,7 @@ import { @@ -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 { @@ -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 { @@ -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 { @@ -1231,6 +1236,7 @@ class ClientService extends EventTarget {
getSessionRelayDebug(): {
scoredRelays: { url: string; successCount: number; avgLatencyMs: number }[]
presetWorking: string[]
relayStrikes: ReturnType<typeof relaySessionStrikes.getDebugSnapshot>
} {
const presetSet = new Set<string>()
for (const u of [
@ -1249,7 +1255,7 @@ class ClientService extends EventTarget { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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<string>()
@ -2240,6 +2265,14 @@ class ClientService extends EventTarget { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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. */

13
src/services/relay-notice-fetch-failure.ts

@ -2,25 +2,22 @@ import type { AbstractRelay } from 'nostr-tools/abstract-relay' @@ -2,25 +2,22 @@ import type { AbstractRelay } from 'nostr-tools/abstract-relay'
const patched = new WeakSet<object>()
/** 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 */
}

Loading…
Cancel
Save