425 lines
10 KiB
TypeScript
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 },
|
|
);
|
|
}
|