Browse Source

bug-fixes

imwald
Silberengel 2 weeks ago
parent
commit
c73cc26a38
  1. 40
      src/lib/profile-metadata-batch.ts
  2. 42
      src/services/nostr-archives-api.service.test.ts
  3. 80
      src/services/nostr-archives-api.service.ts
  4. 1
      src/types/nostr-archives.ts

40
src/lib/profile-metadata-batch.ts

@ -6,6 +6,7 @@ import { @@ -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 { @@ -29,9 +30,20 @@ function placeholderProfile(pubkey: string): TProfile {
}
}
function mergeArchivesProfiles(
byPk: Map<string, TProfile>,
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<TProfile[]> {
const deduped = normalizeHexPubkeys(pubkeys)
@ -41,21 +53,17 @@ export async function fetchProfilesMetadataBatch(pubkeys: readonly string[]): Pr @@ -41,21 +53,17 @@ export async function fetchProfilesMetadataBatch(pubkeys: readonly string[]): Pr
try {
const byPk = new Map<string, TProfile>()
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 })
}
}

42
src/services/nostr-archives-api.service.test.ts

@ -38,19 +38,36 @@ describe('NostrArchivesApiService circuit breaker', () => { @@ -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<Response>((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', () => { @@ -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<Response>((_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)
})
})

80
src/services/nostr-archives-api.service.ts

@ -21,11 +21,14 @@ import type { @@ -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( @@ -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 { @@ -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<string, number>({ max: 4096 })
private fetchInFlight = new Map<string, Promise<TArchivesApiResult<unknown>>>()
constructor() {
if (!NostrArchivesApiService.instance) {
@ -87,6 +113,7 @@ class NostrArchivesApiService { @@ -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 { @@ -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 { @@ -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<TArchivesApiResult<unknown>> {
if (!this.consumeRateLimit()) {
return { ok: false, reason: 'rate_limited' }
}
@ -143,6 +212,7 @@ class NostrArchivesApiService { @@ -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 { @@ -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' }

1
src/types/nostr-archives.ts

@ -10,6 +10,7 @@ export type TArchivesApiResult<T> = @@ -10,6 +10,7 @@ export type TArchivesApiResult<T> =
| 'circuit_open'
| 'rate_limited'
| 'network'
| 'timeout'
| 'http'
| 'not_found'
| 'parse'

Loading…
Cancel
Save