Files
tech-log-frontend/src/contracts/errors.ts
T

425 lines
10 KiB
TypeScript

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<Kind extends string> = Readonly<{
kind: Kind;
defaultRetryable: boolean;
severity: string;
userMessageKey: string;
action: ErrorAction;
telemetryEvent: string;
redaction: readonly string[];
}>;
const row = <Kind extends string>(
kind: Kind,
defaultRetryable: boolean,
severity: string,
action: ErrorAction,
telemetryEvent = "api.request.failed",
): ErrorDefinition<Kind> =>
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<FailureKind> = 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<FailureEffectCertainty, "NOT_APPLICABLE">,
): 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 },
);
}