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