Files
pikasTech-HWLAB/web/hwlab-cloud-web/src/api/client.ts
T
2026-06-20 15:38:11 +08:00

366 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)}swaitingFor=${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;
}