const DROP_SENSITIVE = Object.freeze([ "cause", "body", "headers", "authorization", "url", "query", "stack", "storageValue", ] as const); export type ErrorAction = | "retry" | "reauth" | "navigate" | "reload-once" | "contact-support" | "none"; export type ErrorDefinition = Readonly<{ kind: Kind; defaultRetryable: boolean; severity: string; userMessageKey: string; action: ErrorAction; telemetryEvent: string; redaction: readonly string[]; }>; const row = ( kind: Kind, defaultRetryable: boolean, severity: string, action: ErrorAction, telemetryEvent = "api.request.failed", ): ErrorDefinition => 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"), RESPONSE_BODY_LIMIT: row( "RESPONSE_BODY_LIMIT", false, "error", "contact-support", ), ENVELOPE_MISMATCH: row("ENVELOPE_MISMATCH", false, "error", "contact-support"), SCHEMA_MISMATCH: row("SCHEMA_MISMATCH", false, "error", "contact-support"), MAPPING_CONTRACT_VIOLATION: row( "MAPPING_CONTRACT_VIOLATION", false, "error", "contact-support", ), RESULT_LIMIT_EXCEEDED: row( "RESULT_LIMIT_EXCEEDED", false, "error", "contact-support", ), SCOPE_GENERATION_CHANGED: row( "SCOPE_GENERATION_CHANGED", false, "info", "none", ), IDENTITY_INTERN_LIMIT_EXCEEDED: row( "IDENTITY_INTERN_LIMIT_EXCEEDED", false, "warning", "retry", ), DUPLICATE_IN_FLIGHT: row( "DUPLICATE_IN_FLIGHT", false, "info", "none", ), PAGINATION_CONTRACT_VIOLATION: row( "PAGINATION_CONTRACT_VIOLATION", 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"), }); /** * Every failure crossing an application input boundary must use one of the * registry-owned kinds. Adapters may accept untrusted backend codes, but must * map those codes to this closed vocabulary before returning. */ export type FailureKind = keyof typeof ERROR_REGISTRY; export type ValidationIssue = Readonly<{ path: string; code: string }>; export type FailureEffectCertainty = | "NOT_APPLICABLE" | "NOT_STARTED" | "NOT_APPLIED" | "APPLIED_CONFIRMED" | "MAYBE_APPLIED"; export type AppFailure = Readonly<{ kind: FailureKind; code: string; httpStatus?: number; retryable: boolean; operationId: string; attemptCount: number; requestId?: string; traceId?: string; retryAfterMs?: number; effect?: FailureEffectCertainty; validationIssues?: readonly ValidationIssue[]; userMessageKey: string; action: ErrorAction; causeClass?: string; }>; /** * Backward-compatible transport-facing name. New application and presentation * code should prefer AppFailure. * */ export type ApiFailure = AppFailure; export type FailureDetails = Readonly<{ code?: string; httpStatus?: number; requestId?: string; traceId?: string; retryAfterMs?: number; effect?: FailureEffectCertainty; validationIssues?: readonly ValidationIssue[]; causeClass?: string; }>; export function createFailure( kind: FailureKind, operationId: string, attempt: number, details: FailureDetails = {}, ): AppFailure { const definition: ErrorDefinition = ERROR_REGISTRY[kind]; return Object.freeze({ kind: definition.kind, code: typeof details.code === "string" ? details.code : definition.kind, retryable: details.effect === "MAYBE_APPLIED" || details.effect === "APPLIED_CONFIRMED" ? false : 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 } : {}), ...(details.effect === undefined ? {} : { effect: details.effect }), ...(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: details.effect === "MAYBE_APPLIED" ? "contact-support" : details.effect === "APPLIED_CONFIRMED" ? "none" : definition.action, }); } /** * Adds controller-owned mutation effect knowledge without weakening the * fail-safe handling required for an unknown server-side outcome. */ export function withFailureEffect( failure: AppFailure, effect: Exclude, ): AppFailure { return Object.freeze({ ...failure, effect, ...(effect === "MAYBE_APPLIED" ? { retryable: false as const, action: "contact-support" as const } : effect === "APPLIED_CONFIRMED" ? { retryable: false as const, action: "none" as const } : {}), }); } /** * 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. * */ export function safeValidationIssues( value: unknown, ): readonly ValidationIssue[] { if (!value || typeof value !== "object") return Object.freeze([]); const candidate = value as Readonly<{ issues?: unknown; fieldErrors?: unknown; }>; const issues = Array.isArray(candidate.issues) ? candidate.issues : Array.isArray(candidate.fieldErrors) ? candidate.fieldErrors : []; return Object.freeze( issues .filter( (issue): issue is ValidationIssue => issue && typeof issue === "object" && "path" in issue && "code" in issue && 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, }), ), ); } export function kindForStatus(status: number): FailureKind { 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. * */ export function normalizeUnknownFailure( value: unknown, context: Readonly<{ operationId?: string; attempt?: number }> = {}, ): AppFailure { 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 }, ); }