const DROP_SENSITIVE = Object.freeze([ "cause", "body", "headers", "authorization", "url", "query", "stack", "storageValue", ]); /** * @typedef {"retry" | "reauth" | "navigate" | "reload-once" | * "contact-support" | "none"} ErrorAction */ /** * @typedef {{ * kind: string, * defaultRetryable: boolean, * severity: string, * userMessageKey: string, * action: ErrorAction, * telemetryEvent: string, * redaction: readonly string[] * }} ErrorDefinition */ /** * @param {string} kind * @param {boolean} defaultRetryable * @param {string} severity * @param {ErrorAction} action * @param {string} [telemetryEvent] * @returns {Readonly} */ const row = ( kind, defaultRetryable, severity, action, telemetryEvent = "api.request.failed", ) => Object.freeze({ kind, defaultRetryable, severity, userMessageKey: `error.${kind.toLowerCase()}`, action, telemetryEvent, redaction: DROP_SENSITIVE, }); export const ERROR_REGISTRY = Object.freeze({ NETWORK_UNREACHABLE: row("NETWORK_UNREACHABLE", true, "warning", "retry"), REQUEST_TIMEOUT: row("REQUEST_TIMEOUT", true, "warning", "retry"), REQUEST_ABORTED: row("REQUEST_ABORTED", false, "info", "none"), CONTENT_TYPE_MISMATCH: row( "CONTENT_TYPE_MISMATCH", false, "error", "contact-support", ), MALFORMED_JSON: row("MALFORMED_JSON", false, "error", "contact-support"), ENVELOPE_MISMATCH: row("ENVELOPE_MISMATCH", false, "error", "contact-support"), SCHEMA_MISMATCH: row("SCHEMA_MISMATCH", false, "error", "contact-support"), AUTH_REQUIRED: row("AUTH_REQUIRED", false, "info", "reauth"), AUTH_INTEGRATION_FAILURE: row( "AUTH_INTEGRATION_FAILURE", false, "error", "contact-support", ), FORBIDDEN: row("FORBIDDEN", false, "warning", "navigate"), NOT_FOUND: row("NOT_FOUND", false, "info", "navigate"), CONFLICT: row("CONFLICT", false, "warning", "retry"), VALIDATION_REJECTED: row("VALIDATION_REJECTED", false, "info", "none"), UNKNOWN_CLIENT_FAILURE: row( "UNKNOWN_CLIENT_FAILURE", false, "warning", "contact-support", ), RATE_LIMITED: row("RATE_LIMITED", true, "warning", "retry"), SERVER_FAILURE: row("SERVER_FAILURE", true, "error", "retry"), CHUNK_LOAD_FAILURE: row( "CHUNK_LOAD_FAILURE", false, "error", "reload-once", "release.mismatch.detected", ), BOOT_CONFIG_FAILURE: row( "BOOT_CONFIG_FAILURE", false, "error", "contact-support", "app.boot.failed", ), RELEASE_MANIFEST_FAILURE: row( "RELEASE_MANIFEST_FAILURE", false, "error", "contact-support", "app.boot.failed", ), DEPLOY_MISMATCH: row( "DEPLOY_MISMATCH", false, "error", "reload-once", "release.mismatch.detected", ), BUILD_MISMATCH: row( "BUILD_MISMATCH", false, "error", "reload-once", "release.mismatch.detected", ), CONFIG_MISMATCH: row( "CONFIG_MISMATCH", false, "error", "contact-support", "app.boot.failed", ), API_CONTRACT_MISMATCH: row( "API_CONTRACT_MISMATCH", false, "error", "contact-support", "app.boot.failed", ), RELEASE_MISMATCH: row( "RELEASE_MISMATCH", false, "error", "reload-once", "release.mismatch.detected", ), ASSET_MISMATCH: row( "ASSET_MISMATCH", false, "error", "reload-once", "release.mismatch.detected", ), STORAGE_UNAVAILABLE: row( "STORAGE_UNAVAILABLE", false, "warning", "none", "storage.operation.failed", ), STORAGE_QUOTA_EXCEEDED: row( "STORAGE_QUOTA_EXCEEDED", false, "warning", "none", "storage.operation.failed", ), RENDER_FAILURE: row( "RENDER_FAILURE", false, "error", "reload-once", "ui.render.failed", ), TELEMETRY_FAILURE: row( "TELEMETRY_FAILURE", false, "info", "none", "telemetry.delivery.dropped", ), QUERY_CACHE_FAILURE: row( "QUERY_CACHE_FAILURE", false, "error", "retry", "query.cache.failed", ), UNKNOWN_FAILURE: row("UNKNOWN_FAILURE", false, "error", "contact-support"), }); /** * @typedef {{ * kind: string, * code: string, * httpStatus?: number, * retryable: boolean, * operationId: string, * attemptCount: number, * requestId?: string, * traceId?: string, * retryAfterMs?: number, * validationIssues?: readonly Readonly<{path: string, code: string}>[], * userMessageKey: string, * action: ErrorAction, * causeClass?: string * }} ApiFailure */ /** * @param {string} kind * @param {string} operationId * @param {number} attempt * @param {{ * code?: string, * httpStatus?: number, * requestId?: string, * traceId?: string, * retryAfterMs?: number, * validationIssues?: readonly Readonly<{path: string, code: string}>[], * causeClass?: string * }} [details] * @returns {ApiFailure} */ export function createFailure(kind, operationId, attempt, details = {}) { const registry = /** @type {Readonly>>} */ ( ERROR_REGISTRY ); const definition = registry[kind] ?? ERROR_REGISTRY.UNKNOWN_FAILURE; return Object.freeze({ kind: definition.kind, code: typeof details.code === "string" ? details.code : definition.kind, retryable: definition.defaultRetryable, operationId, attemptCount: Math.max(1, attempt + 1), ...(Number.isInteger(details.httpStatus) ? { httpStatus: details.httpStatus } : {}), ...(typeof details.requestId === "string" ? { requestId: details.requestId } : {}), ...(typeof details.traceId === "string" ? { traceId: details.traceId } : {}), ...(typeof details.retryAfterMs === "number" ? { retryAfterMs: details.retryAfterMs } : {}), ...(Array.isArray(details.validationIssues) ? { validationIssues: Object.freeze( details.validationIssues .filter( (issue) => issue && typeof issue === "object" && typeof issue.path === "string" && typeof issue.code === "string", ) .slice(0, 50) .map((issue) => Object.freeze({ path: issue.path, code: issue.code }), ), ), } : {}), ...(typeof details.causeClass === "string" ? { causeClass: details.causeClass } : {}), userMessageKey: definition.userMessageKey, action: definition.action, }); } /** * Projects an untrusted 422 details payload into the only validation metadata * allowed to cross the HTTP boundary. Backend copy and additional values are * deliberately discarded. * * @param {unknown} value * @returns {readonly Readonly<{path: string, code: string}>[]} */ export function safeValidationIssues(value) { if (!value || typeof value !== "object") return Object.freeze([]); const candidate = /** @type {{issues?: unknown, fieldErrors?: unknown}} */ (value); const issues = Array.isArray(candidate.issues) ? candidate.issues : Array.isArray(candidate.fieldErrors) ? candidate.fieldErrors : []; return Object.freeze( issues .filter( (issue) => issue && typeof issue === "object" && typeof issue.path === "string" && typeof issue.code === "string" && issue.path.length <= 120 && issue.code.length <= 80, ) .slice(0, 50) .map((issue) => Object.freeze({ path: issue.path, code: issue.code, }), ), ); } /** @param {number} status */ export function kindForStatus(status) { if (status === 401) return "AUTH_REQUIRED"; if (status === 403) return "FORBIDDEN"; if (status === 404) return "NOT_FOUND"; if (status === 409) return "CONFLICT"; if (status === 422) return "VALIDATION_REJECTED"; if (status === 429) return "RATE_LIMITED"; if (status >= 500) return "SERVER_FAILURE"; if (status >= 400) return "UNKNOWN_CLIENT_FAILURE"; return "ENVELOPE_MISMATCH"; } /** * Total catch-all that intentionally discards the thrown value. * * @param {unknown} value * @param {{ operationId?: string, attempt?: number }} [context] */ export function normalizeUnknownFailure(value, context = {}) { const causeClass = value instanceof Error ? value.name : value === null ? "null" : typeof value; return createFailure( "UNKNOWN_FAILURE", context.operationId ?? "UNKNOWN_OPERATION", context.attempt ?? 0, { code: "UNKNOWN_FAILURE", causeClass }, ); }