366 lines
16 KiB
TypeScript
366 lines
16 KiB
TypeScript
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<T>(path: string, options: ApiRequestOptions = {}): Promise<ApiResult<T>> {
|
||
const controller = new AbortController();
|
||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||
const activitySource = resolveActivitySource(options.activityRef);
|
||
let timer: ReturnType<typeof setTimeout> | 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<T>(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<T>(path, options, `${options.timeoutName ?? path} 超时`, "browser_timeout", "timeout");
|
||
}
|
||
return browserFailure<T>(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<ApiResult<string>> {
|
||
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<string>(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<T>(path: string, options: ApiRequestOptions, message: string, code: string, category: string): ApiResult<T> {
|
||
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<unknown> {
|
||
return isRecord(value) && typeof value.ok === "boolean" && typeof value.status === "number" && "data" in value && "error" in value;
|
||
}
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
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<string, unknown>;
|
||
const nested = record.error && typeof record.error === "object" ? record.error as Record<string, unknown> : 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;
|
||
}
|