Files
clean-architecture-frontend…/src/contracts/errors.js
T

339 lines
8.5 KiB
JavaScript

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<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",
),
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<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 }
: {}),
...(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 },
);
}