/** * @factory/shared — canonical API error model. * * Every backend operation surfaces failures as an {@link ApiError} with a * stable {@link ErrorCode}. The same codes are understood by the web client, * the API, the worker and the realtime service, so error handling stays * consistent across the whole platform (see ARCHITECTURE.md §3 "Error model"). */ /** Stable machine-readable error codes. Never change the values. */ export const ErrorCodes = { VALIDATION_ERROR: "VALIDATION_ERROR", UNAUTHORIZED: "UNAUTHORIZED", FORBIDDEN: "FORBIDDEN", NOT_FOUND: "NOT_FOUND", CONFLICT: "CONFLICT", RATE_LIMITED: "RATE_LIMITED", NOT_IMPLEMENTED: "NOT_IMPLEMENTED", SEARCH_UNAVAILABLE: "SEARCH_UNAVAILABLE", STORAGE_ERROR: "STORAGE_ERROR", STORAGE_TIMEOUT: "STORAGE_TIMEOUT", // File pipeline (Phase 9, docs/tasks/phase-09-files.md TASK-0101/0102/0104). FILE_TOO_LARGE: "FILE_TOO_LARGE", QUOTA_EXCEEDED: "QUOTA_EXCEEDED", MIME_MISMATCH: "MIME_MISMATCH", UPLOAD_INCOMPLETE: "UPLOAD_INCOMPLETE", UPLOAD_EXPIRED: "UPLOAD_EXPIRED", FILE_QUARANTINED: "FILE_QUARANTINED", AI_ERROR: "AI_ERROR", TIMEOUT: "TIMEOUT", INTERNAL: "INTERNAL", } as const; export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; /** * Map from {@link ErrorCode} → default HTTP status. Individual errors may * override `status` (e.g. a validation error that is also a 404). */ export const ERROR_STATUS: Record = { VALIDATION_ERROR: 422, UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, CONFLICT: 409, RATE_LIMITED: 429, NOT_IMPLEMENTED: 501, SEARCH_UNAVAILABLE: 503, STORAGE_ERROR: 502, STORAGE_TIMEOUT: 504, // File pipeline (Phase 9): FILE_TOO_LARGE: 413, QUOTA_EXCEEDED: 413, MIME_MISMATCH: 415, UPLOAD_INCOMPLETE: 422, UPLOAD_EXPIRED: 410, FILE_QUARANTINED: 403, AI_ERROR: 502, TIMEOUT: 504, INTERNAL: 500, }; /** Wire shape of an error in JSON API responses. */ export interface ApiErrorJson { error: { code: ErrorCode; message: string; status: number; requestId?: string; details?: unknown; }; } export interface ApiErrorOptions { /** Human readable message (safe to show; details carry structured data). */ message: string; /** HTTP status to respond with. Defaults to {@link ERROR_STATUS}. */ status?: number; /** Structured, machine-readable details (e.g. per-field validation errors). */ details?: unknown; /** Correlation id for logs; set by the request scope if absent. */ requestId?: string; /** Error this one wraps (kept internal, never serialized). */ cause?: unknown; } /** * The single error class thrown across the platform. * * ```ts * throw new ApiError({ code: "NOT_FOUND", message: "Page not found" }); * ``` */ export class ApiError extends Error { readonly code: ErrorCode; readonly status: number; readonly details?: unknown; readonly requestId?: string; constructor(code: ErrorCode, options: ApiErrorOptions) { super(options.message, options.cause !== undefined ? { cause: options.cause } : undefined); this.name = "ApiError"; this.code = code; this.status = options.status ?? ERROR_STATUS[code]; if (options.details !== undefined) this.details = options.details; if (options.requestId !== undefined) this.requestId = options.requestId; // Preserve stack trace in V8 (Error.captureStackTrace is not in lib.dom). if ( typeof (Error as { captureStackTrace?: (t: object, f?: Function) => void }) .captureStackTrace === "function" ) { ( Error as unknown as { captureStackTrace: (t: object, f?: Function) => void } ).captureStackTrace(this, ApiError); } } /** Serialize to the canonical wire shape. */ toJSON(): ApiErrorJson["error"] { const out: ApiErrorJson["error"] = { code: this.code, message: this.message, status: this.status, }; if (this.requestId !== undefined) out.requestId = this.requestId; if (this.details !== undefined) out.details = this.details; return out; } } /** * Build an {@link ApiError} from a raw JSON body (e.g. rehydrating an error * that crossed a network boundary, or in tests). Tolerant of partial input: * unknown/missing code falls back to INTERNAL. */ export function toApiError(json: Partial | ApiError): ApiError { if (json instanceof ApiError) return json; // Tolerant of partial/unknown input — treat the body as the full wire shape // and narrow each field by runtime check below (exactOptionalPropertyTypes: // an absent field is omitted, never passed as `undefined`). const body = ((json as Partial | undefined)?.error ?? {}) as ApiErrorJson["error"]; const code = (Object.values(ErrorCodes) as string[]).includes(body.code) ? (body.code as ErrorCode) : "INTERNAL"; const message = typeof body.message === "string" && body.message.length > 0 ? body.message : "Internal error"; const requestId = typeof body.requestId === "string" ? body.requestId : undefined; return new ApiError(code, { message, ...(typeof body.status === "number" ? { status: body.status } : {}), ...(body.details !== undefined ? { details: body.details } : {}), ...(requestId !== undefined ? { requestId } : {}), }); } /** * Normalize any thrown value into an {@link ApiError}. * * - {@link ApiError} passes through (requestId filled in when missing). * - Zod `ZodError` → `VALIDATION_ERROR` with per-field details. * - Objects with a known `code`/`status` are adopted. * - Anything else → `INTERNAL` (original error kept as `cause`, message is * intentionally generic so internals never leak to clients). */ export function fromException(err: unknown, requestId?: string): ApiError { if (err instanceof ApiError) { const id = err.requestId ?? requestId; return id !== undefined ? withRequestId(err, id) : err; } // Zod errors (duck-typed so this module does not import zod here — zod is // a dependency of the shared package anyway, but keeping it loose avoids a // circular import at module init time). if (isZodError(err)) { return new ApiError("VALIDATION_ERROR", { message: "Request validation failed", details: err.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message, })), ...(requestId !== undefined ? { requestId } : {}), }); } if (err instanceof Error) { const anyErr = err as Error & { code?: string; status?: number; details?: unknown }; const code = (Object.values(ErrorCodes) as string[]).includes(anyErr.code ?? "") ? (anyErr.code as ErrorCode) : "INTERNAL"; return new ApiError(code, { message: code === "INTERNAL" ? "Internal error" : err.message, ...(typeof anyErr.status === "number" ? { status: anyErr.status } : {}), ...(anyErr.details !== undefined ? { details: anyErr.details } : {}), ...(requestId !== undefined ? { requestId } : {}), ...(code === "INTERNAL" ? { cause: err } : {}), }); } return new ApiError("INTERNAL", { message: "Internal error", ...(requestId !== undefined ? { requestId } : {}), }); } function withRequestId(err: ApiError, requestId: string): ApiError { if (err.requestId === requestId) return err; const copy = new ApiError(err.code, { message: err.message, status: err.status, ...(err.details !== undefined ? { details: err.details } : {}), requestId, cause: err, }); return copy; } function isZodError( err: unknown, ): err is { issues: { path: (string | number)[]; message: string }[] } { return ( typeof err === "object" && err !== null && "issues" in err && Array.isArray((err as { issues?: unknown }).issues) ); }