Files
pikasTech-HWLAB/internal/cloud/error-envelope.ts
T

142 lines
5.4 KiB
TypeScript

export const HWLAB_ERROR_DIAGNOSTIC_VERSION = "hwlab-error-diagnostic-v1";
export function decorateErrorPayloadForHttp(payload, options = {}) {
const statusCode = numericStatus(options.statusCode);
if (!statusCode || statusCode < 400 || !plainObject(payload) || !("error" in payload)) return payload;
const context = options.context ?? options.response?.hwlabHttpRequestContext ?? options.request?.hwlabHttpRequestContext ?? {};
const originalError = normalizeErrorObject(payload.error, payload);
if (!originalError) return payload;
const rawCode = originalError.code ?? payload.code ?? payload.error ?? statusCodeToCode(statusCode);
const diagnosticCode = safeCode(rawCode);
const errorCode = typeof originalError.code === "number" ? originalError.code : diagnosticCode;
const message = firstText(originalError.message, payload.message, diagnosticCode);
const userMessage = firstText(originalError.userMessage, payload.userMessage, message);
const diagnostic = {
contractVersion: HWLAB_ERROR_DIAGNOSTIC_VERSION,
traceId: safeOtelTraceId(context.traceId) ?? safeOtelTraceId(originalError.diagnostic?.traceId) ?? safeOtelTraceId(originalError.otelTraceId) ?? safeOtelTraceId(payload.otelTraceId) ?? null,
requestId: safeRequestId(context.requestId) ?? safeRequestId(originalError.diagnostic?.requestId) ?? safeRequestId(originalError.requestId) ?? safeRequestId(payload.requestId) ?? null,
serviceId: safeToken(originalError.serviceId ?? payload.serviceId ?? options.serviceId, "hwlab-cloud-api"),
route: safeRoute(originalError.route ?? payload.route ?? options.route ?? context.route),
layer: safeToken(originalError.layer ?? options.layer, "api"),
category: safeToken(originalError.category ?? options.category, categoryForStatus(statusCode)),
code: diagnosticCode,
httpStatus: statusCode,
source: safeToken(options.source, "server"),
observedAt: new Date().toISOString(),
valuesPrinted: false
};
const nextError = {
...originalError,
code: errorCode,
message,
userMessage,
diagnostic: {
...(plainObject(originalError.diagnostic) ? originalError.diagnostic : {}),
...diagnostic
},
valuesPrinted: originalError.valuesPrinted === true ? true : false
};
return { ...payload, error: nextError };
}
export function logErrorEnvelope(payload, options = {}) {
const statusCode = numericStatus(options.statusCode);
if (!statusCode || statusCode < 400 || !plainObject(payload) || !plainObject(payload.error)) return;
const diagnostic = plainObject(payload.error.diagnostic) ? payload.error.diagnostic : null;
if (!diagnostic) return;
const record = {
event: "hwlab.error",
statusCode,
trace_id: diagnostic.traceId ?? null,
request_id: diagnostic.requestId ?? null,
route: diagnostic.route ?? null,
serviceId: diagnostic.serviceId ?? "hwlab-cloud-api",
error: {
code: payload.error.code ?? diagnostic.code ?? "unknown_error",
layer: payload.error.layer ?? diagnostic.layer ?? "api",
category: payload.error.category ?? diagnostic.category ?? categoryForStatus(statusCode),
retryable: payload.error.retryable ?? null
},
valuesPrinted: false
};
const logger = options.logger ?? console;
const line = JSON.stringify(record);
if (statusCode >= 500 && typeof logger.error === "function") {
logger.error(line);
} else if (typeof logger.warn === "function") {
logger.warn(line);
}
}
function normalizeErrorObject(value, payload) {
if (plainObject(value)) return { ...value };
const text = firstText(value, payload?.message, "unknown_error");
return {
code: safeCode(text),
message: text,
userMessage: text
};
}
function numericStatus(value) {
const status = Number(value);
return Number.isInteger(status) && status >= 100 && status <= 599 ? status : null;
}
function statusCodeToCode(statusCode) {
if (statusCode === 400) return "bad_request";
if (statusCode === 401) return "auth_required";
if (statusCode === 403) return "forbidden";
if (statusCode === 404) return "not_found";
if (statusCode === 409) return "conflict";
if (statusCode === 429) return "rate_limited";
if (statusCode >= 500) return "internal_error";
return "error";
}
function categoryForStatus(statusCode) {
if (statusCode >= 500) return "server";
if (statusCode >= 400) return "client";
return "error";
}
function firstText(...values) {
for (const value of values) {
const text = String(value ?? "").trim();
if (text) return text;
}
return "unknown_error";
}
function safeCode(value) {
const text = firstText(value).toLowerCase().replace(/[^a-z0-9_.:-]+/gu, "_").replace(/^_+|_+$/gu, "");
return text.slice(0, 96) || "unknown_error";
}
function safeToken(value, fallback) {
const text = String(value ?? "").trim();
return /^[A-Za-z0-9_.:-]{1,96}$/u.test(text) ? text : fallback;
}
function safeRoute(value) {
const text = String(value ?? "").trim().split("?")[0] || "/";
if (!text.startsWith("/")) return "/";
return text.slice(0, 180);
}
function safeRequestId(value) {
const text = String(value ?? "").trim();
return /^[A-Za-z0-9_.:-]{3,180}$/u.test(text) ? text : null;
}
function safeOtelTraceId(value) {
const text = String(value ?? "").trim().toLowerCase();
return /^[0-9a-f]{32}$/u.test(text) && text !== "00000000000000000000000000000000" ? text : null;
}
function plainObject(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}