diff --git a/src/lib/profile-metadata-batch.ts b/src/lib/profile-metadata-batch.ts index 6c0acae0..cda50bb1 100644 --- a/src/lib/profile-metadata-batch.ts +++ b/src/lib/profile-metadata-batch.ts @@ -6,6 +6,7 @@ import { } from '@/lib/profile-batch-coordinator' import client from '@/services/client.service' import nostrArchivesApi from '@/services/nostr-archives-api.service' +import type { TArchivesApiResult, TArchivesProfileMetadata } from '@/types/nostr-archives' import type { TProfile } from '@/types' function normalizeHexPubkeys(pubkeys: readonly string[]): string[] { @@ -29,9 +30,20 @@ function placeholderProfile(pubkey: string): TProfile { } } +function mergeArchivesProfiles( + byPk: Map, + res: TArchivesApiResult<{ profiles: TArchivesProfileMetadata[] }> +): void { + if (!res.ok) return + for (const meta of res.data.profiles) { + const profile = archivesMetadataToProfile(meta) + if (profile) byPk.set(profile.pubkey, profile) + } +} + /** - * Batch profile hydration: Nostr Archives `POST /v1/profiles/metadata` first, then - * {@link client.fetchProfilesForPubkeys} for pubkeys Archives did not return. + * Batch profile hydration: Nostr Archives `POST /v1/profiles/metadata` and relay fetch run in + * parallel so a slow or missing Archives response never blocks relay fallbacks (feeds stay populated). */ export async function fetchProfilesMetadataBatch(pubkeys: readonly string[]): Promise { const deduped = normalizeHexPubkeys(pubkeys) @@ -41,21 +53,17 @@ export async function fetchProfilesMetadataBatch(pubkeys: readonly string[]): Pr try { const byPk = new Map() - if (nostrArchivesApi.isAvailable()) { - const res = await nostrArchivesApi.fetchProfilesMetadata(deduped) - if (res.ok) { - for (const meta of res.data.profiles) { - const profile = archivesMetadataToProfile(meta) - if (profile) byPk.set(profile.pubkey, profile) - } - } - } + const relayPromise = client.fetchProfilesForPubkeys(deduped).catch(() => [] as TProfile[]) + const archivesPromise = nostrArchivesApi.isAvailable() + ? nostrArchivesApi.fetchProfilesMetadata(deduped) + : Promise.resolve({ ok: false as const, reason: 'disabled' as const }) + + const [archivesRes, relayProfiles] = await Promise.all([archivesPromise, relayPromise]) + mergeArchivesProfiles(byPk, archivesRes) - const missing = deduped.filter((pk) => !byPk.has(pk)) - if (missing.length > 0) { - const relayProfiles = await client.fetchProfilesForPubkeys(missing) - for (const p of relayProfiles) { - const pkNorm = p.pubkey.toLowerCase() + for (const p of relayProfiles) { + const pkNorm = p.pubkey.toLowerCase() + if (!byPk.has(pkNorm)) { byPk.set(pkNorm, { ...p, pubkey: pkNorm }) } } diff --git a/src/services/nostr-archives-api.service.test.ts b/src/services/nostr-archives-api.service.test.ts index a138e4f4..145b8389 100644 --- a/src/services/nostr-archives-api.service.test.ts +++ b/src/services/nostr-archives-api.service.test.ts @@ -38,19 +38,36 @@ describe('NostrArchivesApiService circuit breaker', () => { }) it('returns not_found for 404 without opening the circuit', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response('not found', { status: 404 })) - ) + const fetchMock = vi.fn(async () => new Response('not found', { status: 404 })) + vi.stubGlobal('fetch', fetchMock) const first = await nostrArchivesApi.getEventById(EVENT_ID) const second = await nostrArchivesApi.getEventById(EVENT_ID) expect(first).toEqual({ ok: false, reason: 'not_found', status: 404 }) expect(second).toEqual({ ok: false, reason: 'not_found', status: 404 }) + expect(fetchMock).toHaveBeenCalledTimes(1) expect(nostrArchivesApi.isAvailable()).toBe(true) }) + it('dedupes concurrent getEventById for the same id', async () => { + let resolveFetch!: () => void + const fetchMock = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = () => resolve(new Response('not found', { status: 404 })) + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const a = nostrArchivesApi.getEventById(EVENT_ID) + const b = nostrArchivesApi.getEventById(EVENT_ID) + resolveFetch() + await Promise.all([a, b]) + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + it('opens circuit after repeated 5xx errors', async () => { vi.stubGlobal( 'fetch', @@ -65,4 +82,21 @@ describe('NostrArchivesApiService circuit breaker', () => { const blocked = await nostrArchivesApi.getEventById(EVENT_ID) expect(blocked).toEqual({ ok: false, reason: 'circuit_open' }) }) + + it('does not open circuit on request timeout', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + () => + new Promise((_resolve, reject) => { + reject(new DOMException('The operation was aborted.', 'AbortError')) + }) + ) + ) + + await nostrArchivesApi.getEventById(EVENT_ID) + await nostrArchivesApi.getEventById(EVENT_ID) + + expect(nostrArchivesApi.isAvailable()).toBe(true) + }) }) diff --git a/src/services/nostr-archives-api.service.ts b/src/services/nostr-archives-api.service.ts index de90f86a..297d2acd 100644 --- a/src/services/nostr-archives-api.service.ts +++ b/src/services/nostr-archives-api.service.ts @@ -21,11 +21,14 @@ import type { TArchivesProfileMetadata, TArchivesSocialGraph } from '@/types/nostr-archives' +import { LRUCache } from 'lru-cache' import type { Event } from 'nostr-tools' const CIRCUIT_FAILURE_THRESHOLD = 2 const CIRCUIT_COOLDOWN_MS = 60_000 const REQUEST_TIMEOUT_MS = 12_000 +/** Skip repeat `/v1/events/{id}` lookups after a 404 for this long (event not in archive). */ +const ARCHIVES_NOT_FOUND_CACHE_TTL_MS = 30 * 60_000 /** Whether a failed response indicates the Archives API is down (vs. missing data or bad input). */ export function isArchivesApiCircuitFailure( @@ -39,6 +42,26 @@ export function isArchivesApiCircuitFailure( return false } +/** Client-side timeout (`AbortController`) — slow response, not necessarily API down. */ +function isFetchAbortError(err: unknown): boolean { + if (err == null || typeof err !== 'object') return false + const rec = err as { name?: unknown; message?: unknown } + const name = typeof rec.name === 'string' ? rec.name : '' + const message = typeof rec.message === 'string' ? rec.message : String(err) + if (name === 'AbortError') return true + return /operation was aborted|aborted/i.test(message) +} + +/** Event id from Archives event lookup paths (`/v1/events/{id}`, interactions, note page). */ +export function eventIdFromArchivesApiPath(path: string): string | undefined { + const base = path.split('?')[0] ?? path + const event = base.match(/^\/v1\/events\/([0-9a-f]{64})(?:\/interactions)?$/i) + if (event) return event[1].toLowerCase() + const page = base.match(/^\/v1\/pages\/note\/([0-9a-f]{64})/i) + if (page) return page[1].toLowerCase() + return undefined +} + class NostrArchivesApiService { static instance: NostrArchivesApiService @@ -46,6 +69,9 @@ class NostrArchivesApiService { private consecutiveFailures = 0 private circuitOpenUntil = 0 private availabilityListeners = new Set<() => void>() + /** Event ids that returned 404 — avoid re-fetching on every thread/feed row. */ + private notFoundEventUntil = new LRUCache({ max: 4096 }) + private fetchInFlight = new Map>>() constructor() { if (!NostrArchivesApiService.instance) { @@ -87,6 +113,7 @@ class NostrArchivesApiService { } private recordFailure(): void { + if (Date.now() < this.circuitOpenUntil) return this.consecutiveFailures += 1 if (this.consecutiveFailures >= CIRCUIT_FAILURE_THRESHOLD) { this.circuitOpenUntil = Date.now() + CIRCUIT_COOLDOWN_MS @@ -103,6 +130,26 @@ class NostrArchivesApiService { this.requestTimestamps = [] this.consecutiveFailures = 0 this.circuitOpenUntil = 0 + this.notFoundEventUntil.clear() + this.fetchInFlight.clear() + } + + private isEventNotFoundCached(eventId: string): boolean { + const exp = this.notFoundEventUntil.get(eventId) + if (exp == null) return false + if (Date.now() >= exp) { + this.notFoundEventUntil.delete(eventId) + return false + } + return true + } + + private markEventNotFound(eventId: string): void { + this.notFoundEventUntil.set(eventId, Date.now() + ARCHIVES_NOT_FOUND_CACHE_TTL_MS) + } + + private fetchCacheKey(path: string, init?: RequestInit): string { + return `${(init?.method ?? 'GET').toUpperCase()} ${path}` } private consumeRateLimit(): boolean { @@ -123,6 +170,28 @@ class NostrArchivesApiService { if (Date.now() < this.circuitOpenUntil) { return { ok: false, reason: 'circuit_open' } } + + const eventId = eventIdFromArchivesApiPath(path) + if (eventId && this.isEventNotFoundCached(eventId)) { + return { ok: false, reason: 'not_found', status: 404 } + } + + const key = this.fetchCacheKey(path, init) + const inflight = this.fetchInFlight.get(key) + if (inflight) return inflight + + const promise = this.fetchJsonNetwork(path, init, eventId).finally(() => { + this.fetchInFlight.delete(key) + }) + this.fetchInFlight.set(key, promise) + return promise + } + + private async fetchJsonNetwork( + path: string, + init: RequestInit | undefined, + eventId: string | undefined + ): Promise> { if (!this.consumeRateLimit()) { return { ok: false, reason: 'rate_limited' } } @@ -143,6 +212,7 @@ class NostrArchivesApiService { clearTimeout(timer) if (res.status === 404) { + if (eventId) this.markEventNotFound(eventId) logger.debug('[nostr-archives] not indexed in archive (404)', { path }) return { ok: false, reason: 'not_found', status: 404 } } @@ -175,11 +245,17 @@ class NostrArchivesApiService { } } catch (err) { clearTimeout(timer) + const aborted = isFetchAbortError(err) + if (aborted) { + logger.debug('[nostr-archives] request timed out — using relay fallbacks', { + path, + timeoutMs: REQUEST_TIMEOUT_MS + }) + return { ok: false, reason: 'timeout' } + } this.recordFailure() - const aborted = err instanceof Error && err.name === 'AbortError' logger.warn('[nostr-archives] API unreachable — using relay fallbacks', { path, - aborted, message: err instanceof Error ? err.message : String(err) }) return { ok: false, reason: 'network' } diff --git a/src/types/nostr-archives.ts b/src/types/nostr-archives.ts index e8424d7d..ea92357a 100644 --- a/src/types/nostr-archives.ts +++ b/src/types/nostr-archives.ts @@ -10,6 +10,7 @@ export type TArchivesApiResult = | 'circuit_open' | 'rate_limited' | 'network' + | 'timeout' | 'http' | 'not_found' | 'parse'