From 7f3569ce3c795b1dbae68f12acbbf47a44cbc677 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 20:52:58 +0900 Subject: [PATCH] feat: normalize failures through a stable registry --- src/adapters/http/client.js | 60 +----- src/application/ports/resource-ports.js | 2 +- src/contracts/errors.js | 240 ++++++++++++++++++++++++ tests/unit/error-classification.test.js | 70 +++++++ 4 files changed, 315 insertions(+), 57 deletions(-) create mode 100644 src/contracts/errors.js create mode 100644 tests/unit/error-classification.test.js diff --git a/src/adapters/http/client.js b/src/adapters/http/client.js index 3831cb5..4c2d922 100644 --- a/src/adapters/http/client.js +++ b/src/adapters/http/client.js @@ -1,5 +1,9 @@ import { systemClock } from "../../application/ports/clock-port.js"; import { getApiOperation } from "../../contracts/api-operations.js"; +import { + createFailure as failure, + kindForStatus as statusKind, +} from "../../contracts/errors.js"; import { retryDelay, shouldRetry } from "./retry-policy.js"; import { validateEnvelope, @@ -36,16 +40,6 @@ const noAuthSession = * { ok: false, error: HttpFailure }} HttpResult */ -/** - * @typedef {{ - * code?: string, - * httpStatus?: number, - * requestId?: string, - * traceId?: string, - * retryAfterMs?: number - * }} FailureDetails - */ - /** * @param {{ * baseUrl: string, @@ -378,52 +372,6 @@ async function recoverSession(authSession, operation, originalFailure) { * @param {FailureDetails} [details] * @returns {HttpFailure} */ -function failure(kind, operationId, attempt, details = {}) { - const retryable = new Set([ - "NETWORK_UNREACHABLE", - "REQUEST_TIMEOUT", - "RATE_LIMITED", - "SERVER_FAILURE", - ]).has(kind); - const action = - kind === "AUTH_REQUIRED" - ? "reauth" - : retryable - ? "retry" - : kind === "REQUEST_ABORTED" - ? "none" - : "contact-support"; - - return Object.freeze({ - kind, - code: details.code ?? kind, - retryable, - operationId, - attemptCount: attempt + 1, - ...(details.httpStatus === undefined ? {} : { httpStatus: details.httpStatus }), - ...(details.requestId ? { requestId: details.requestId } : {}), - ...(details.traceId ? { traceId: details.traceId } : {}), - ...(details.retryAfterMs === undefined - ? {} - : { retryAfterMs: details.retryAfterMs }), - userMessageKey: `error.${kind.toLowerCase()}`, - action, - }); -} - -/** @param {number} status */ -function statusKind(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"; -} - /** @param {unknown} envelope */ function safeBackendCode(envelope) { if (!envelope || typeof envelope !== "object") return "HTTP_FAILURE"; diff --git a/src/application/ports/resource-ports.js b/src/application/ports/resource-ports.js index 6a07788..1290758 100644 --- a/src/application/ports/resource-ports.js +++ b/src/application/ports/resource-ports.js @@ -22,7 +22,7 @@ /** * @template Value * @typedef {{ ok: true, value: Value, meta?: Record } | - * { ok: false, error: unknown }} Result + * { ok: false, error: import("../../contracts/errors.js").ApiFailure }} Result */ export {}; diff --git a/src/contracts/errors.js b/src/contracts/errors.js new file mode 100644 index 0000000..ee69137 --- /dev/null +++ b/src/contracts/errors.js @@ -0,0 +1,240 @@ +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 }, + ); +} diff --git a/tests/unit/error-classification.test.js b/tests/unit/error-classification.test.js new file mode 100644 index 0000000..4de3b47 --- /dev/null +++ b/tests/unit/error-classification.test.js @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { + ERROR_REGISTRY, + createFailure, + kindForStatus, + normalizeUnknownFailure, +} from "../../src/contracts/errors.js"; + +describe("frontend failure classification", () => { + it("defines all 26 stable error kinds with the seven contract fields", () => { + expect(Object.keys(ERROR_REGISTRY)).toHaveLength(26); + for (const definition of Object.values(ERROR_REGISTRY)) { + expect(definition).toEqual( + expect.objectContaining({ + kind: expect.any(String), + defaultRetryable: expect.any(Boolean), + severity: expect.any(String), + userMessageKey: expect.any(String), + action: expect.any(String), + telemetryEvent: expect.any(String), + redaction: expect.any(Array), + }), + ); + } + }); + + it.each([ + [401, "AUTH_REQUIRED"], + [403, "FORBIDDEN"], + [404, "NOT_FOUND"], + [409, "CONFLICT"], + [422, "VALIDATION_REJECTED"], + [418, "UNKNOWN_CLIENT_FAILURE"], + [429, "RATE_LIMITED"], + [503, "SERVER_FAILURE"], + ])("maps HTTP %i to %s", (status, kind) => { + expect(kindForStatus(status)).toBe(kind); + }); + + it("projects only allowlisted safe fields", () => { + const result = createFailure("SERVER_FAILURE", "LIST_SAMPLE_RESOURCES", 0, { + code: "TEMPORARY", + httpStatus: 503, + requestId: "request-1", + stack: "must not leak", + body: "must not leak", + authorization: "Bearer secret", + }); + + expect(result).toMatchObject({ + kind: "SERVER_FAILURE", + code: "TEMPORARY", + httpStatus: 503, + requestId: "request-1", + }); + expect(JSON.stringify(result)).not.toMatch(/stack|body|Bearer|secret/); + }); + + it("normalizes any thrown value without leaking it", () => { + const secret = { token: "sensitive", nested: { rawBody: "private" } }; + const result = normalizeUnknownFailure(secret); + + expect(result).toMatchObject({ + kind: "UNKNOWN_FAILURE", + causeClass: "object", + }); + expect(JSON.stringify(result)).not.toMatch(/sensitive|private|token|rawBody/); + }); +});