import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from "@nestjs/common"; import { Prisma } from "@prisma/client"; import { ZodError } from "zod"; import type { Request, Response } from "express"; import { ApiError, type ErrorCode, type ErrorDetail } from "../errors.js"; import { logger } from "../logger.js"; import { scrubSecrets } from "../log-scrubber.js"; import { m } from "@factory/observability"; /** * Global exception filter. Maps everything to the canonical envelope: * { error: { code, message, details?, requestId } } * * - ApiError → its own code/status * - TokenError (jwt) → 401 INVALID_TOKEN / TOKEN_EXPIRED * - ZodError → 400 VALIDATION_ERROR with details[] * - HttpException → mapped (404→NOT_FOUND, 409→CONFLICT, 422→VALIDATION_ERROR, 413→PAYLOAD_TOO_LARGE, 429→RATE_LIMITED, 501→NOT_IMPLEMENTED, 503→SERVICE_UNAVAILABLE, others pass through or INTERNAL_ERROR) * - Prisma known errors → P2002 duplicate → 409 CONFLICT; P2025 missing → 404 NOT_FOUND * - anything else → 500 INTERNAL_ERROR (logged, message generalized) */ @Catch() export class AllExceptionsFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost): void { const ctx = host.switchToHttp(); const res = ctx.getResponse(); const req = ctx.getRequest(); const requestId = req.id ?? "unknown"; const mapped = this.map(exception, req); const status = mapped.status; // TASK-0135: the spec's error_total{service, code} — every handled error // counts (the error-rate alert divides 5xx by all requests from the // http_request_count metrics; this counter is the code-level view for the // API Overview dashboard). m.error.record({ service: "factory-api", code: mapped.code }); if (status >= 500) { logger.error(scrubSecrets(`unhandled error on ${req.method} ${req.originalUrl}`), { requestId, name: exception instanceof Error ? exception.name : typeof exception, message: scrubSecrets(exception instanceof Error ? exception.message : String(exception)), stack: exception instanceof Error ? scrubSecrets(String(exception.stack ?? "")) : null }); } else if (status >= 400) { logger.warn(scrubSecrets(`request failed ${status} on ${req.method} ${req.originalUrl}`), { requestId, code: mapped.code, message: scrubSecrets(mapped.message) }); } res.status(status).json({ error: { code: mapped.code, message: mapped.message, ...(mapped.details && mapped.details.length > 0 ? { details: mapped.details } : {}), requestId } }); } private map(exception: unknown, req: Request): { status: number; code: ErrorCode; message: string; details?: ErrorDetail[] } { if (exception instanceof ApiError) { return { status: exception.httpStatus, code: exception.code, message: exception.message, details: exception.details }; } if (isTokenErrorLike(exception)) { return { status: 401, code: exception.code, message: exception.message, details: [{ reason: exception.code === "TOKEN_EXPIRED" ? "token_expired" : "token_malformed" }] }; } if (exception instanceof ZodError) { const details: ErrorDetail[] = exception.issues.slice(0, 20).map((i) => ({ field: i.path.join("."), issue: i.message })); return { status: 400, code: "VALIDATION_ERROR", message: "Request validation failed.", details }; } if (exception instanceof Prisma.PrismaClientKnownRequestError) { if (exception.code === "P2002") { const target = Array.isArray(exception.meta?.target) ? (exception.meta?.target as string[]).join(", ") : String(exception.meta?.target ?? ""); return { status: 409, code: "CONFLICT", message: "A record with the same unique value already exists.", details: [{ field: target, issue: "already_exists" }] }; } if (exception.code === "P2025") { return { status: 404, code: "NOT_FOUND", message: "Resource not found." }; } if (exception.code === "P2003") { return { status: 409, code: "CONFLICT", message: "Operation violates a relational constraint.", details: [{ issue: "foreign_key_constraint", received: exception.meta }] }; } return { status: 400, code: "UNPROCESSABLE_ENTITY", message: "Database request could not be completed.", details: [{ issue: exception.code }] }; } if (exception instanceof Prisma.PrismaClientValidationError) { return { status: 400, code: "VALIDATION_ERROR", message: "Invalid database query.", details: [] }; } if (exception instanceof HttpException) { const status = exception.getStatus(); const code = this.httpCode(status); let message = "Request failed."; let details: ErrorDetail[] | undefined; const body = exception.getResponse(); if (typeof body === "string") message = body; else if (body && typeof body === "object") { const b = body as Record; if (typeof b.message === "string") message = b.message; else if (Array.isArray(b.message)) message = b.message.join("; "); if (b.details) details = b.details as ErrorDetail[]; if (b.code) details = [...(details ?? []), { issue: String(b.code) }]; } return { status, code, message, details }; } // 500: original message is logged in catch(); the client gets a generic one. return { status: 500, code: "INTERNAL_ERROR", message: "Internal server error." }; } private httpCode(status: number): ErrorCode { switch (status) { case 400: return "VALIDATION_ERROR"; case 401: return "UNAUTHORIZED"; case 403: return "FORBIDDEN"; case 404: return "NOT_FOUND"; case 405: return "NOT_FOUND"; case 409: return "CONFLICT"; case 410: return "GONE"; case 412: return "PRECONDITION_FAILED"; case 413: return "PAYLOAD_TOO_LARGE"; case 422: return "VALIDATION_ERROR"; case 429: return "RATE_LIMITED"; case 501: return "NOT_IMPLEMENTED"; case 503: return "SERVICE_UNAVAILABLE"; default: return "INTERNAL_ERROR"; } } } interface TokenErrorLike { code: "INVALID_TOKEN" | "TOKEN_EXPIRED"; message: string; } function isTokenErrorLike(e: unknown): e is TokenErrorLike { return typeof e === "object" && e !== null && "code" in e && ((e as TokenErrorLike).code === "INVALID_TOKEN" || (e as TokenErrorLike).code === "TOKEN_EXPIRED"); }