import type { ApiError, ApiResult, ErrorDiagnostic } from "@/types"; import { recordApiTiming } from "@/utils/rum"; import { recordWorkbenchApiRequest } from "@/utils/workbench-performance"; export interface ActivityRef { lastActivityAt: number; lastActivityIso?: string; waitingFor?: string | null; lastEventLabel?: string | null; } export type ActivityRefSource = (() => ActivityRef | null) | null; export interface ApiRequestOptions extends RequestInit { timeoutMs?: number; timeoutName?: string; activityRef?: ActivityRef | ActivityRefSource; } export interface ApiErrorContext { error: string | null; apiError: ApiError | null; diagnostic: ErrorDiagnostic | null; status?: number | null; } const ACTIVITY_RESCHEDULER_MS = 1000; const DEFAULT_TIMEOUT_MS = 4500; export async function fetchJson(path: string, options: ApiRequestOptions = {}): Promise> { const controller = new AbortController(); const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const activitySource = resolveActivitySource(options.activityRef); let timer: ReturnType | null = null; const startedAt = Date.now(); const inactivityRef: { current: { idleMs: number; lastActivityAt: number; lastActivityIso?: string; waitingFor?: string | null; lastEventLabel?: string | null } | null } = { current: null }; function readActivity(): ActivityRef { const fallback = { lastActivityAt: startedAt, lastActivityIso: new Date(startedAt).toISOString() }; if (!activitySource) return fallback; const ref = activitySource(); if (!ref || !Number.isFinite(ref.lastActivityAt) || ref.lastActivityAt <= 0) return fallback; return ref; } function schedule(): void { if (timer !== null) { clearTimeout(timer); timer = null; } if (!activitySource) { timer = setTimeout(() => controller.abort(), timeoutMs); return; } const activity = readActivity(); const idleMs = Math.max(0, Date.now() - activity.lastActivityAt); const remaining = timeoutMs - idleMs; if (remaining <= 0) { inactivityRef.current = { idleMs, lastActivityAt: activity.lastActivityAt, lastActivityIso: activity.lastActivityIso, waitingFor: activity.waitingFor, lastEventLabel: activity.lastEventLabel }; controller.abort(); return; } timer = setTimeout(schedule, Math.max(250, Math.min(remaining, ACTIVITY_RESCHEDULER_MS))); } schedule(); try { const response = await fetch(path, { ...options, credentials: options.credentials ?? "same-origin", headers: { accept: "application/json", ...(options.body ? { "content-type": "application/json" } : {}), ...(options.headers ?? {}) }, signal: controller.signal }); const payload = await response.json().catch(() => null) as T | null; recordApiTiming({ route: path, method: options.method, status: response.status, startedAt, outcome: response.ok ? "ok" : "http_error" }); recordWorkbenchApiRequest({ route: path, method: options.method, status: response.status, startedAtEpochMs: startedAt, endedAtEpochMs: Date.now(), outcome: response.ok ? "ok" : "http_error", timeoutName: options.timeoutName }); const context = response.ok ? emptyErrorContext() : responseErrorContext(payload, response, path, `HTTP ${response.status}`); return { ok: response.ok, status: response.status, data: payload, error: context.error, apiError: context.apiError, diagnostic: context.diagnostic }; } catch (error) { const outcome = error instanceof DOMException && error.name === "AbortError" ? "timeout" : "network_error"; const activity = inactivityRef.current; recordApiTiming({ route: path, method: options.method, status: 0, startedAt, outcome }); recordWorkbenchApiRequest({ route: path, method: options.method, status: 0, startedAtEpochMs: startedAt, endedAtEpochMs: Date.now(), outcome, timeoutName: options.timeoutName, activityIdleMs: activity?.idleMs, activityWaitingFor: activity?.waitingFor, activityLastEventLabel: activity?.lastEventLabel, errorName: error instanceof Error ? error.name : undefined }); if (error instanceof DOMException && error.name === "AbortError") { if (activity) { const detail = activity; return browserFailure(path, options, `${options.timeoutName ?? path} 超过 ${timeoutMs}ms 无新活动(idle ${Math.round(detail.idleMs / 1000)}s;waitingFor=${detail.waitingFor ?? "unknown"};lastEventLabel=${detail.lastEventLabel ?? "none"})`, "browser_timeout", "timeout"); } return browserFailure(path, options, `${options.timeoutName ?? path} 超时`, "browser_timeout", "timeout"); } return browserFailure(path, options, error instanceof Error ? error.message : String(error), "browser_network_error", "network"); } finally { if (timer !== null) clearTimeout(timer); } } export async function fetchText(path: string, options: ApiRequestOptions = {}): Promise> { const startedAt = Date.now(); try { const response = await fetch(path, { ...options, credentials: options.credentials ?? "same-origin" }); const text = await response.text(); recordApiTiming({ route: path, method: options.method, status: response.status, startedAt, outcome: response.ok ? "ok" : "http_error" }); recordWorkbenchApiRequest({ route: path, method: options.method, status: response.status, startedAtEpochMs: startedAt, endedAtEpochMs: Date.now(), outcome: response.ok ? "ok" : "http_error", timeoutName: options.timeoutName }); const context = response.ok ? emptyErrorContext() : responseErrorContext(null, response, path, `HTTP ${response.status}`); return { ok: response.ok, status: response.status, data: text, error: context.error, apiError: context.apiError, diagnostic: context.diagnostic }; } catch (error) { recordApiTiming({ route: path, method: options.method, status: 0, startedAt, outcome: "network_error" }); recordWorkbenchApiRequest({ route: path, method: options.method, status: 0, startedAtEpochMs: startedAt, endedAtEpochMs: Date.now(), outcome: "network_error", timeoutName: options.timeoutName, errorName: error instanceof Error ? error.name : undefined }); return browserFailure(path, options, error instanceof Error ? error.message : String(error), "browser_network_error", "network"); } } export function apiErrorContextFromUnknown(err: unknown, fallback = "操作失败"): ApiErrorContext { if (isApiResultLike(err)) { const diagnostic = normalizeDiagnostic(err.diagnostic) ?? normalizeDiagnostic(err.apiError?.diagnostic) ?? null; const apiError = normalizeApiError(err.apiError, err.error ?? fallback, diagnostic); const message = firstString(err.error, apiError?.userMessage, apiError?.message, fallback) ?? fallback; return { error: message, apiError, diagnostic, status: err.status }; } if (err instanceof Error) return { error: err.message || fallback, apiError: null, diagnostic: null }; if (isRecord(err)) { const nested = isRecord(err.error) ? err.error : null; const diagnostic = normalizeDiagnostic(err.diagnostic) ?? normalizeDiagnostic(nested?.diagnostic) ?? null; const apiError = normalizeApiError(nested ?? err.apiError, firstString(err.message, err.error, fallback) ?? fallback, diagnostic); const message = firstString(err.message, err.reason, err.error, apiError?.userMessage, apiError?.message, fallback) ?? fallback; return { error: message, apiError, diagnostic, status: firstNumber(err.status) }; } return { error: String(err || fallback), apiError: null, diagnostic: null }; } function resolveActivitySource(ref: ApiRequestOptions["activityRef"]): ActivityRefSource { if (typeof ref === "function") return ref; if (ref && typeof ref === "object") return () => ref; return null; } function emptyErrorContext(): ApiErrorContext { return { error: null, apiError: null, diagnostic: null }; } function responseErrorContext(payload: unknown, response: Response, path: string, fallback: string): ApiErrorContext { const diagnostic = responseDiagnostic(payload, response, path); const message = errorMessage(payload, fallback); return { error: message, apiError: responseApiError(payload, path, message, diagnostic), diagnostic, status: response.status }; } function responseApiError(payload: unknown, path: string, message: string, diagnostic: ErrorDiagnostic): ApiError { const record = isRecord(payload) ? payload : null; const nested = record && isRecord(record.error) ? record.error : null; const source = nested ?? record ?? {}; const normalized = normalizeApiError(source, message, diagnostic) ?? { message, diagnostic }; return { ...normalized, message, userMessage: firstString(normalized.userMessage, message), code: firstStringOrNumber(normalized.code, diagnostic.code), retryable: firstBoolean(normalized.retryable, diagnostic.retryable), layer: firstString(normalized.layer, diagnostic.layer), category: firstString(normalized.category, diagnostic.category), route: firstString(normalized.route, diagnostic.route, path), traceId: firstString(normalized.traceId, diagnostic.traceId), requestId: firstString(normalized.requestId, diagnostic.requestId), source: firstString(normalized.source, diagnostic.source, "server"), diagnostic, valuesPrinted: normalized.valuesPrinted === true ? true : diagnostic.valuesPrinted === true }; } function responseDiagnostic(payload: unknown, response: Response, path: string): ErrorDiagnostic { const record = isRecord(payload) ? payload : null; const nested = record && isRecord(record.error) ? record.error : null; const body = normalizeDiagnostic(nested?.diagnostic) ?? normalizeDiagnostic(record?.diagnostic) ?? {}; const headerTraceId = firstString(response.headers.get("x-hwlab-otel-trace-id"), traceIdFromTraceparent(response.headers.get("traceparent"))); const requestId = response.headers.get("x-request-id"); return { ...body, contractVersion: firstString(body.contractVersion, "hwlab-error-diagnostic-v1"), traceId: firstString(headerTraceId, body.traceId, nested?.traceId, record?.traceId), requestId: firstString(requestId, body.requestId, nested?.requestId, record?.requestId), route: firstString(body.route, nested?.route, record?.route, path), layer: firstString(body.layer, nested?.layer, record?.layer), category: firstString(body.category, nested?.category, record?.category), code: firstStringOrNumber(body.code, nested?.code, record?.code), httpStatus: firstNumber(body.httpStatus, response.status) ?? response.status, serviceId: firstString(body.serviceId, nested?.serviceId, record?.serviceId), source: firstString(body.source, "server"), observedAt: firstString(body.observedAt, new Date().toISOString()), retryable: firstBoolean(body.retryable, nested?.retryable, record?.retryable), valuesPrinted: body.valuesPrinted === true }; } function browserFailure(path: string, options: ApiRequestOptions, message: string, code: string, category: string): ApiResult { const diagnostic = browserDiagnostic(path, options, code, category); return { ok: false, status: 0, data: null, error: message, apiError: { message, userMessage: message, code, retryable: true, layer: "web", category, route: path, traceId: diagnostic.traceId, requestId: diagnostic.requestId, source: "browser", diagnostic, valuesPrinted: false }, diagnostic }; } function browserDiagnostic(path: string, options: ApiRequestOptions, code: string, category: string): ErrorDiagnostic { return { contractVersion: "hwlab-error-diagnostic-v1", traceId: randomTraceId(), requestId: randomRequestId(), route: path, layer: "web", category, code, httpStatus: 0, source: "browser", observedAt: new Date().toISOString(), retryable: true, timeoutName: options.timeoutName ?? null, valuesPrinted: false }; } function normalizeApiError(value: unknown, fallback: string, diagnostic: ErrorDiagnostic | null): ApiError | null { if (!isRecord(value)) return null; const normalizedDiagnostic = normalizeDiagnostic(value.diagnostic) ?? diagnostic; return { ...value, message: firstString(value.message, value.userMessage, value.reason, value.code, fallback) ?? fallback, userMessage: firstString(value.userMessage, value.message), code: firstStringOrNumber(value.code, normalizedDiagnostic?.code), reason: firstString(value.reason), retryable: firstBoolean(value.retryable, normalizedDiagnostic?.retryable), layer: firstString(value.layer, normalizedDiagnostic?.layer), category: firstString(value.category, normalizedDiagnostic?.category), route: firstString(value.route, normalizedDiagnostic?.route), traceId: firstString(value.traceId, normalizedDiagnostic?.traceId), requestId: firstString(value.requestId, normalizedDiagnostic?.requestId), source: firstString(value.source, normalizedDiagnostic?.source), diagnostic: normalizedDiagnostic, valuesPrinted: value.valuesPrinted === true } as ApiError; } function normalizeDiagnostic(value: unknown): ErrorDiagnostic | null { if (!isRecord(value)) return null; return { ...value, contractVersion: firstString(value.contractVersion, value.contract_version), traceId: firstString(value.traceId, value.trace_id, value.otelTraceId, value.otel_trace_id), requestId: firstString(value.requestId, value.request_id), serviceId: firstString(value.serviceId, value.service_id), route: firstString(value.route), layer: firstString(value.layer), category: firstString(value.category), code: firstStringOrNumber(value.code), httpStatus: firstNumber(value.httpStatus, value.http_status), source: firstString(value.source), observedAt: firstString(value.observedAt, value.observed_at), retryable: firstBoolean(value.retryable), valuesPrinted: value.valuesPrinted === true }; } function isApiResultLike(value: unknown): value is ApiResult { return isRecord(value) && typeof value.ok === "boolean" && typeof value.status === "number" && "data" in value && "error" in value; } function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function firstString(...values: unknown[]): string | null { for (const value of values) { if (typeof value === "string" && value.trim()) return value.trim(); } return null; } function firstStringOrNumber(...values: unknown[]): string | number | null { for (const value of values) { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim()) return value.trim(); } return null; } function firstNumber(...values: unknown[]): number | null { for (const value of values) { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value); } return null; } function firstBoolean(...values: unknown[]): boolean | null { for (const value of values) { if (typeof value === "boolean") return value; } return null; } function traceIdFromTraceparent(value: string | null): string | null { const match = /^[\da-f]{2}-([\da-f]{32})-[\da-f]{16}-[\da-f]{2}$/iu.exec(value?.trim() ?? ""); const traceId = match?.[1]?.toLowerCase() ?? null; return traceId && !/^0+$/u.test(traceId) ? traceId : null; } function randomTraceId(): string { const bytes = new Uint8Array(16); if (globalThis.crypto?.getRandomValues) { globalThis.crypto.getRandomValues(bytes); } else { for (let index = 0; index < bytes.length; index += 1) bytes[index] = Math.floor(Math.random() * 256); } return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } function randomRequestId(): string { return `req_browser_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } function errorMessage(payload: unknown, fallback: string): string { if (payload && typeof payload === "object") { const record = payload as Record; const nested = record.error && typeof record.error === "object" ? record.error as Record : null; const candidate = nested?.userMessage ?? nested?.message ?? nested?.code ?? record.message ?? record.reason ?? record.error; if (typeof candidate === "string" && candidate.trim()) return candidate; } return fallback; }