Skip to content
Clone
f0a1552
249 lines 8.3 KB
/**
* Global API-health tracker — the app-wide「api error」intermediate state.
*
* Every settled transport result (the generated Alova client plus the few raw
* `fetch` call sites, see {@link trackFetch}) is pushed into a rolling window
* here. The service is declared down when *every* recent request failed, i.e. the
* error rate reached {@link API_ERROR_RATE_THRESHOLD} (100%), checked two ways:
*
* 1. window rule — the last {@link WINDOW_SIZE} requests hold at least
* {@link API_ERROR_MIN_SAMPLES} results and every one of them failed;
* 2. burst rule — {@link API_ERROR_BURST_FAILURES} requests failed in a row with
* no answered request in between, which is 100% of the burst since the
* service last answered.
*
* The burst rule is what makes a mid-session crash visible: the window rule
* alone would first have to flush up to {@link WINDOW_SIZE} stale successes.
* `ApiErrorGate` (src/app) then swaps the whole tree for the 服务暂不可用
* screen instead of letting every panel render its own broken state.
*
* What counts as a failure: connection-level errors and HTTP 5xx / status 0.
* 4xx is deliberately *not* a failure — it proves the service is alive and
* simply refused, so a bad token or a closed form must never look like an
* outage.
*
* Recovery is self-service: while the gate is up the store probes a cheap
* public endpoint every {@link API_PROBE_INTERVAL_MS} and the first answered
* request lifts the gate again, which remounts the tree and refetches
* everything without a document reload.
*/
import { useSyncExternalStore } from 'react'
/**
* Error rate at which the service is declared unavailable: 1 = 100%, so the
* gate only appears once nothing the app asked for succeeds any more. Evaluated
* with `>=`, since a rate can never exceed 1.
*/
export const API_ERROR_RATE_THRESHOLD = 1
/** Results below this number are never enough to judge the service. */
export const API_ERROR_MIN_SAMPLES = 3
/**
* Consecutive failures with no successful request in between — the 100% case
* measured over the current burst, and the rule that actually catches an
* outage. Without it, a service that dies mid-session would take up to
* {@link WINDOW_SIZE} failed requests before the gate could appear.
*/
export const API_ERROR_BURST_FAILURES = 3
/** Size of the rolling window the reported rate is computed over. */
const WINDOW_SIZE = 20
/** Liveness-probe cadence while the gate is up (ms). */
export const API_PROBE_INTERVAL_MS = 4_000
/** Unauthenticated endpoint (redis-backed, no DB rows written) used as probe. */
const PROBE_URL = '/api/v1/auth/pow/challenge'
export interface ApiResult {
/** HTTP status; 0 means "no response at all". */
status: number
/** Request url, kept only so the outage screen can name the last failure. */
url?: string
/** Transport-level failure (connection refused, offline, DNS, reset). */
networkError?: boolean
}
export interface ApiHealth {
/** True while the outage gate is showing. */
unavailable: boolean
/** Settled requests inside the window. */
samples: number
/** Failed requests inside the window. */
failures: number
/** failures / samples, 0 when the window is empty. */
errorRate: number
/** Failed requests since the last one the service answered. */
burstFailures: number
lastFailureStatus: number | null
lastFailureUrl: string | null
/** A probe round-trip is currently in flight. */
probing: boolean
lastProbeAt: number | null
}
/** `true` = request settled successfully *as far as service health goes*. */
let outcomes: boolean[] = []
let burstFailures = 0
let unavailable = false
let probing = false
let lastProbeAt: number | null = null
let lastFailureStatus: number | null = null
let lastFailureUrl: string | null = null
let probeTimer: ReturnType<typeof setTimeout> | null = null
const listeners = new Set<() => void>()
let snapshot: ApiHealth = buildSnapshot()
function buildSnapshot(): ApiHealth {
const samples = outcomes.length
const failures = outcomes.reduce((n, ok) => (ok ? n : n + 1), 0)
return {
unavailable,
samples,
failures,
errorRate: samples ? failures / samples : 0,
burstFailures,
lastFailureStatus,
lastFailureUrl,
probing,
lastProbeAt,
}
}
function sameSnapshot(a: ApiHealth, b: ApiHealth): boolean {
return (
a.unavailable === b.unavailable &&
a.samples === b.samples &&
a.failures === b.failures &&
a.errorRate === b.errorRate &&
a.burstFailures === b.burstFailures &&
a.lastFailureStatus === b.lastFailureStatus &&
a.lastFailureUrl === b.lastFailureUrl &&
a.probing === b.probing &&
a.lastProbeAt === b.lastProbeAt
)
}
function emit(): void {
const next = buildSnapshot()
if (sameSnapshot(next, snapshot)) return
snapshot = next
for (const listener of [...listeners]) listener()
}
export function getApiHealth(): ApiHealth {
return snapshot
}
export function subscribeApiHealth(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
function ensureProbe(): void {
if (!unavailable || probeTimer !== null) return
probeTimer = setTimeout(() => void runProbe(), API_PROBE_INTERVAL_MS)
}
function stopProbe(): void {
if (probeTimer === null) return
clearTimeout(probeTimer)
probeTimer = null
}
/** Feed one settled transport result into the window and re-evaluate the gate. */
export function recordApiResult(result: ApiResult): void {
const failed = result.networkError === true || result.status === 0 || result.status >= 500
if (failed) {
lastFailureStatus = result.status
lastFailureUrl = result.url ?? null
burstFailures += 1
} else {
burstFailures = 0
}
outcomes.push(!failed)
if (outcomes.length > WINDOW_SIZE) outcomes.shift()
const samples = outcomes.length
const failures = outcomes.reduce((n, ok) => (ok ? n : n + 1), 0)
const rate = samples ? failures / samples : 0
const windowTripped = samples >= API_ERROR_MIN_SAMPLES && rate >= API_ERROR_RATE_THRESHOLD
const burstTripped = burstFailures >= API_ERROR_BURST_FAILURES
if (!unavailable) {
if (windowTripped || burstTripped) unavailable = true
} else if (!failed) {
// The service answered once → hand control back to the app and let the
// remounted views refetch; a still-broken backend trips the gate again.
unavailable = false
stopProbe()
}
ensureProbe()
emit()
}
async function runProbe(): Promise<void> {
if (probeTimer !== null) {
clearTimeout(probeTimer)
probeTimer = null
}
// A record() that arrived while this probe was in flight already armed the
// next tick, and a probe that is still running must not overlap itself.
if (probing || !unavailable) return
probing = true
emit()
try {
const res = await fetch(PROBE_URL, { headers: { accept: 'application/json' } })
lastProbeAt = Date.now()
recordApiResult({ status: res.status, url: PROBE_URL })
} catch {
lastProbeAt = Date.now()
recordApiResult({ status: 0, url: PROBE_URL, networkError: true })
} finally {
probing = false
// Re-arm from the finally block so a slow response can never end the loop.
ensureProbe()
emit()
}
}
/** Retry immediately (the outage screen's button). */
export function retryApiHealth(): void {
void runProbe()
}
/**
* `fetch` wrapper for the call sites that cannot use the generated client
* (HTML fragments, binary avatar uploads) so their results reach the same
* health window. AbortError is rethrown untouched and never counted — the
* caller cancelled it, the service did not.
*/
export async function trackFetch(input: string, init?: RequestInit): Promise<Response> {
try {
const res = await fetch(input, init)
recordApiResult({ status: res.status, url: res.url || input })
return res
} catch (err) {
if ((err as Error | null)?.name !== 'AbortError') {
recordApiResult({ status: 0, url: input, networkError: true })
}
throw err
}
}
const getUnavailable = () => getApiHealth().unavailable
/** Reactive view of the outage flag only — parents re-render on flips alone. */
export function useApiUnavailable(): boolean {
return useSyncExternalStore(subscribeApiHealth, getUnavailable, getUnavailable)
}
/** Full health snapshot, for the outage screen. */
export function useApiHealth(): ApiHealth {
return useSyncExternalStore(subscribeApiHealth, getApiHealth, getApiHealth)
}