Browse Source

bug-fixes

imwald
Silberengel 2 weeks ago
parent
commit
0466b0d119
  1. 68
      src/services/nostr-archives-api.service.test.ts
  2. 43
      src/services/nostr-archives-api.service.ts
  3. 9
      src/types/nostr-archives.ts

68
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' })
})
})

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

@ -27,6 +27,18 @@ const CIRCUIT_FAILURE_THRESHOLD = 2
const CIRCUIT_COOLDOWN_MS = 60_000 const CIRCUIT_COOLDOWN_MS = 60_000
const REQUEST_TIMEOUT_MS = 12_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 { class NostrArchivesApiService {
static instance: NostrArchivesApiService static instance: NostrArchivesApiService
@ -78,11 +90,21 @@ class NostrArchivesApiService {
this.consecutiveFailures += 1 this.consecutiveFailures += 1
if (this.consecutiveFailures >= CIRCUIT_FAILURE_THRESHOLD) { if (this.consecutiveFailures >= CIRCUIT_FAILURE_THRESHOLD) {
this.circuitOpenUntil = Date.now() + CIRCUIT_COOLDOWN_MS 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() 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 { private consumeRateLimit(): boolean {
const now = Date.now() const now = Date.now()
const windowStart = now - 60_000 const windowStart = now - 60_000
@ -120,8 +142,21 @@ class NostrArchivesApiService {
}) })
clearTimeout(timer) 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) { if (!res.ok) {
if (isArchivesApiCircuitFailure('http', res.status)) {
this.recordFailure() 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 } return { ok: false, reason: 'http', status: res.status }
} }
@ -132,13 +167,17 @@ class NostrArchivesApiService {
return { ok: true, data } return { ok: true, data }
} catch { } catch {
this.recordFailure() 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 } return { ok: false, reason: 'parse', status: res.status }
} }
} catch (err) { } catch (err) {
clearTimeout(timer) clearTimeout(timer)
this.recordFailure() this.recordFailure()
const aborted = err instanceof Error && err.name === 'AbortError' const aborted = err instanceof Error && err.name === 'AbortError'
logger.debug('[nostr-archives] fetch failed', { logger.warn('[nostr-archives] API unreachable — using relay fallbacks', {
path, path,
aborted, aborted,
message: err instanceof Error ? err.message : String(err) message: err instanceof Error ? err.message : String(err)

9
src/types/nostr-archives.ts

@ -5,7 +5,14 @@ export type TArchivesApiResult<T> =
| { ok: true; data: T } | { ok: true; data: T }
| { | {
ok: false ok: false
reason: 'disabled' | 'circuit_open' | 'rate_limited' | 'network' | 'http' | 'parse' reason:
| 'disabled'
| 'circuit_open'
| 'rate_limited'
| 'network'
| 'http'
| 'not_found'
| 'parse'
status?: number status?: number
} }

Loading…
Cancel
Save