diff --git a/src/lib/index-relay-http.test.ts b/src/lib/index-relay-http.test.ts new file mode 100644 index 00000000..66765100 --- /dev/null +++ b/src/lib/index-relay-http.test.ts @@ -0,0 +1,28 @@ +import { + IndexRelayTransportError, + clearDevIndexRelayUnavailableThisSession, + isDevIndexRelayUnavailableThisSession, + isIndexRelayTransportFailure +} from '@/lib/index-relay-http' +import { describe, expect, it, beforeEach } from 'vitest' + +describe('isIndexRelayTransportFailure', () => { + it('treats IndexRelayTransportError as transport failure', () => { + expect(isIndexRelayTransportFailure(new IndexRelayTransportError())).toBe(true) + expect(isIndexRelayTransportFailure(new IndexRelayTransportError(new Error('HTTP 500')))).toBe(true) + }) + + it('treats network TypeError as transport failure', () => { + expect(isIndexRelayTransportFailure(new TypeError('Failed to fetch'))).toBe(true) + }) +}) + +describe('dev index relay session skip', () => { + beforeEach(() => { + clearDevIndexRelayUnavailableThisSession() + }) + + it('starts available after clear', () => { + expect(isDevIndexRelayUnavailableThisSession()).toBe(false) + }) +}) diff --git a/src/lib/index-relay-http.ts b/src/lib/index-relay-http.ts index a739d43a..226b705c 100644 --- a/src/lib/index-relay-http.ts +++ b/src/lib/index-relay-http.ts @@ -79,6 +79,7 @@ function warnIndexRelayHttpThrottled(endpoint: string, message: string, meta: Re /** True when the relay cannot be reached (down, DNS, browser blocked, etc.). Not HTTP 4xx/5xx from a live server. */ export function isIndexRelayTransportFailure(err: unknown): boolean { + if (err instanceof IndexRelayTransportError) return true if (err == null || typeof err !== 'object') return false const e = err as Error & { name?: string; cause?: unknown } if (e.name === 'AbortError') return false @@ -107,6 +108,39 @@ function isDevViteIndexRelayProxyPath(endpoint: string): boolean { ) } +/** + * When the Vite `/dev-index-relay` proxy returns 5xx, skip further dev index HTTP fetches for this tab + * (same pattern as optional `/sites/` and translate proxies). Cleared on a successful filter response. + */ +let devIndexRelayUnavailableThisSession = false +let devIndexRelaySkipLogged = false + +export function isDevIndexRelayUnavailableThisSession(): boolean { + return devIndexRelayUnavailableThisSession +} + +export function clearDevIndexRelayUnavailableThisSession(): void { + devIndexRelayUnavailableThisSession = false + devIndexRelaySkipLogged = false +} + +function markDevIndexRelayUnavailableFromHttpStatus(status: number, endpoint: string): void { + if (!isDevViteIndexRelayProxyPath(endpoint)) return + if (status < 500 || status > 599) return + if (devIndexRelayUnavailableThisSession) return + devIndexRelayUnavailableThisSession = true + if (!devIndexRelaySkipLogged) { + devIndexRelaySkipLogged = true + logger.debug( + `[IndexRelayHttp] Dev index relay returned HTTP ${status}; skipping further dev-index-relay fetches this session (other relays continue).` + ) + } +} + +function shouldSkipDevIndexRelayFetch(endpoint: string): boolean { + return import.meta.env.DEV && devIndexRelayUnavailableThisSession && isDevViteIndexRelayProxyPath(endpoint) +} + function maybeLogDevIndexRelayUnreachableHint(): void { if (import.meta.env.PROD || typeof window === 'undefined') return const now = Date.now() @@ -191,6 +225,9 @@ export async function queryIndexRelay( const filters = Array.isArray(filter) ? filter : [filter] const out: NEvent[] = [] const seen = new Set() + if (shouldSkipDevIndexRelayFetch(endpoint)) { + return out + } for (const f of filters) { const body = nostrFilterToIndexRelayBody(filterForIndexRelay(f)) try { @@ -213,6 +250,7 @@ export async function queryIndexRelay( /* ignore */ } if (res.status >= 500 && res.status <= 599) { + markDevIndexRelayUnavailableFromHttpStatus(res.status, endpoint) maybeLogDevIndexRelayHttpErrorHint(res.status, detail || undefined) } else { logger.debug('[IndexRelayHttp] filter HTTP response', { @@ -228,10 +266,12 @@ export async function queryIndexRelay( }) } if (res.status >= 500) { + markDevIndexRelayUnavailableFromHttpStatus(res.status, endpoint) throw new IndexRelayTransportError(new Error(`HTTP ${res.status}`)) } continue } + clearDevIndexRelayUnavailableThisSession() const json = (await res.json()) as { data?: unknown } const data = json.data if (!Array.isArray(data)) continue @@ -245,11 +285,12 @@ export async function queryIndexRelay( } } catch (e) { if ((e as Error).name === 'AbortError') throw e + if (e instanceof IndexRelayTransportError) throw e if (isIndexRelayTransportFailure(e)) { handleFilterTransportFailure(endpoint, e) - } else { - warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] filter request error', { endpoint, error: e }) + throw new IndexRelayTransportError(e) } + warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] filter request error', { endpoint, error: e }) } } return out diff --git a/src/lib/relay-strikes.test.ts b/src/lib/relay-strikes.test.ts index 0c0c717c..3b00ab53 100644 --- a/src/lib/relay-strikes.test.ts +++ b/src/lib/relay-strikes.test.ts @@ -43,6 +43,20 @@ describe('relaySessionStrikes.observeSubscribeBatch', () => { }) }) +describe('relaySessionStrikes HTTP read failures', () => { + beforeEach(() => { + relaySessionStrikes.reset() + }) + + it('session-skips after five parallel HTTP failures (no debounce)', () => { + const url = 'https://index.example.com/' + for (let i = 0; i < 5; i++) { + relaySessionStrikes.recordReadFailure(url, 'http') + } + expect(relaySessionStrikes.isReadHttpSkipped(url)).toBe(true) + }) +}) + describe('relaySessionStrikes.clearKey', () => { beforeEach(() => { relaySessionStrikes.reset() diff --git a/src/lib/relay-strikes.ts b/src/lib/relay-strikes.ts index fe0b0c10..e287ea3e 100644 --- a/src/lib/relay-strikes.ts +++ b/src/lib/relay-strikes.ts @@ -174,7 +174,8 @@ class RelaySessionStrikes { if (now < e.rateLimitUntil && !this.cacheRelayKeys.has(key)) return if (!this.cacheRelayKeys.has(key)) { - if (now - e.readLastStrikeIncrementAt < STRIKE_INCREMENT_DEBOUNCE_MS) return + // HTTP index failures often arrive in parallel; count each so session skip engages quickly. + if (_source !== 'http' && now - e.readLastStrikeIncrementAt < STRIKE_INCREMENT_DEBOUNCE_MS) return e.readLastStrikeIncrementAt = now } diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 184421e3..1be77363 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -475,7 +475,7 @@ export class QueryService { urls .filter((u) => isHttpRelayUrl(u)) .map((u) => normalizeHttpRelayUrl(u) || u) - .filter(Boolean) + .filter((u): u is string => Boolean(u) && !relaySessionStrikes.isReadHttpSkipped(u)) ) ) const wsQueryUrls = urls.filter((u) => !isHttpRelayUrl(u))