From 0466b0d1197354d7b61247d95d5de2743283c258 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 4 Jun 2026 21:10:13 +0200 Subject: [PATCH] bug-fixes --- .../nostr-archives-api.service.test.ts | 68 +++++++++++++++++++ src/services/nostr-archives-api.service.ts | 45 +++++++++++- src/types/nostr-archives.ts | 9 ++- 3 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 src/services/nostr-archives-api.service.test.ts diff --git a/src/services/nostr-archives-api.service.test.ts b/src/services/nostr-archives-api.service.test.ts new file mode 100644 index 00000000..a138e4f4 --- /dev/null +++ b/src/services/nostr-archives-api.service.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import nostrArchivesApi, { isArchivesApiCircuitFailure } from '@/services/nostr-archives-api.service' + +vi.mock('@/services/local-storage.service', () => ({ + default: { + getUseNostrArchivesApi: () => true + } +})) + +const EVENT_ID = '3047103e518909910b1fc10ca1f4cb0122e6e28f497fa2ad1f41e6e7e7adf3cf' + +describe('isArchivesApiCircuitFailure', () => { + it('treats 404 as healthy API (missing data)', () => { + expect(isArchivesApiCircuitFailure('http', 404)).toBe(false) + }) + + it('treats 5xx and network/parse as service failures', () => { + expect(isArchivesApiCircuitFailure('http', 500)).toBe(true) + expect(isArchivesApiCircuitFailure('http', 503)).toBe(true) + expect(isArchivesApiCircuitFailure('network')).toBe(true) + expect(isArchivesApiCircuitFailure('parse')).toBe(true) + }) + + it('does not trip circuit on other 4xx', () => { + expect(isArchivesApiCircuitFailure('http', 400)).toBe(false) + expect(isArchivesApiCircuitFailure('http', 403)).toBe(false) + }) +}) + +describe('NostrArchivesApiService circuit breaker', () => { + beforeEach(() => { + nostrArchivesApi.resetForTests() + }) + + afterEach(() => { + vi.unstubAllGlobals() + nostrArchivesApi.resetForTests() + }) + + it('returns not_found for 404 without opening the circuit', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('not found', { status: 404 })) + ) + + 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(nostrArchivesApi.isAvailable()).toBe(true) + }) + + it('opens circuit after repeated 5xx errors', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('error', { status: 503 })) + ) + + await nostrArchivesApi.getEventById(EVENT_ID) + await nostrArchivesApi.getEventById(EVENT_ID) + + expect(nostrArchivesApi.isAvailable()).toBe(false) + + const blocked = await nostrArchivesApi.getEventById(EVENT_ID) + expect(blocked).toEqual({ ok: false, reason: 'circuit_open' }) + }) +}) diff --git a/src/services/nostr-archives-api.service.ts b/src/services/nostr-archives-api.service.ts index a287a5c4..de90f86a 100644 --- a/src/services/nostr-archives-api.service.ts +++ b/src/services/nostr-archives-api.service.ts @@ -27,6 +27,18 @@ const CIRCUIT_FAILURE_THRESHOLD = 2 const CIRCUIT_COOLDOWN_MS = 60_000 const REQUEST_TIMEOUT_MS = 12_000 +/** Whether a failed response indicates the Archives API is down (vs. missing data or bad input). */ +export function isArchivesApiCircuitFailure( + reason: 'network' | 'parse' | 'http', + status?: number +): boolean { + if (reason === 'network' || reason === 'parse') return true + if (status == null) return true + if (status === 404) return false + if (status >= 500) return true + return false +} + class NostrArchivesApiService { static instance: NostrArchivesApiService @@ -78,11 +90,21 @@ class NostrArchivesApiService { this.consecutiveFailures += 1 if (this.consecutiveFailures >= CIRCUIT_FAILURE_THRESHOLD) { this.circuitOpenUntil = Date.now() + CIRCUIT_COOLDOWN_MS - logger.info('[nostr-archives] API circuit open', { cooldownMs: CIRCUIT_COOLDOWN_MS }) + logger.info( + '[nostr-archives] API paused after repeated errors — relay fallbacks only; retrying automatically', + { failures: this.consecutiveFailures, retryAfterMs: CIRCUIT_COOLDOWN_MS } + ) this.notifyAvailability() } } + /** @internal Vitest only — reset circuit breaker and rate-limit window. */ + resetForTests(): void { + this.requestTimestamps = [] + this.consecutiveFailures = 0 + this.circuitOpenUntil = 0 + } + private consumeRateLimit(): boolean { const now = Date.now() const windowStart = now - 60_000 @@ -120,8 +142,21 @@ class NostrArchivesApiService { }) clearTimeout(timer) + if (res.status === 404) { + logger.debug('[nostr-archives] not indexed in archive (404)', { path }) + return { ok: false, reason: 'not_found', status: 404 } + } + if (!res.ok) { - this.recordFailure() + if (isArchivesApiCircuitFailure('http', res.status)) { + this.recordFailure() + logger.warn('[nostr-archives] API error — using relay fallbacks', { + path, + status: res.status + }) + } else { + logger.debug('[nostr-archives] request rejected', { path, status: res.status }) + } return { ok: false, reason: 'http', status: res.status } } @@ -132,13 +167,17 @@ class NostrArchivesApiService { return { ok: true, data } } catch { this.recordFailure() + logger.warn('[nostr-archives] API returned invalid JSON — using relay fallbacks', { + path, + status: res.status + }) return { ok: false, reason: 'parse', status: res.status } } } catch (err) { clearTimeout(timer) this.recordFailure() const aborted = err instanceof Error && err.name === 'AbortError' - logger.debug('[nostr-archives] fetch failed', { + logger.warn('[nostr-archives] API unreachable — using relay fallbacks', { path, aborted, message: err instanceof Error ? err.message : String(err) diff --git a/src/types/nostr-archives.ts b/src/types/nostr-archives.ts index e009885f..e8424d7d 100644 --- a/src/types/nostr-archives.ts +++ b/src/types/nostr-archives.ts @@ -5,7 +5,14 @@ export type TArchivesApiResult = | { ok: true; data: T } | { ok: false - reason: 'disabled' | 'circuit_open' | 'rate_limited' | 'network' | 'http' | 'parse' + reason: + | 'disabled' + | 'circuit_open' + | 'rate_limited' + | 'network' + | 'http' + | 'not_found' + | 'parse' status?: number }