/** * 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 | 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 { 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 { 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) }