You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
247 lines
8.0 KiB
247 lines
8.0 KiB
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()
|
|
|