Merge branch 'feature-frontend-error-classification-boundary-contract' into develop
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
/**
|
||||
* @template Value
|
||||
* @typedef {{ ok: true, value: Value, meta?: Record<string, unknown> } |
|
||||
* { ok: false, error: unknown }} Result
|
||||
* { ok: false, error: import("../../contracts/errors.js").ApiFailure }} Result
|
||||
*/
|
||||
|
||||
export {};
|
||||
|
||||
@@ -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<ErrorDefinition>}
|
||||
*/
|
||||
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<Record<string, Readonly<ErrorDefinition>>>} */ (
|
||||
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 },
|
||||
);
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user