const DROP_SENSITIVE = Object.freeze([ "cause", "body", "headers", "authorization", "url", "query", "stack", "storageValue", ]); /** * @typedef {{ * kind: string, * defaultRetryable: boolean, * severity: string, * userMessageKey: string, * action: string, * telemetryEvent: string, * redaction: readonly string[] * }} ErrorDefinition */ /** * @param {string} kind * @param {boolean} defaultRetryable * @param {string} severity * @param {string} 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", ), 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, * userMessageKey: string, * action: string, * causeClass?: string * }} ApiFailure */ /** * @param {string} kind * @param {string} operationId * @param {number} attempt * @param {{ * code?: string, * httpStatus?: number, * requestId?: string, * traceId?: string, * retryAfterMs?: number, * 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 } : {}), ...(typeof details.causeClass === "string" ? { causeClass: details.causeClass } : {}), userMessageKey: definition.userMessageKey, action: definition.action, }); } /** @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 }, ); }