All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m15s
194 lines
6.0 KiB
TypeScript
194 lines
6.0 KiB
TypeScript
// src/middleware/errorHandler.ts
|
|
// ============================================================================
|
|
// CENTRALIZED ERROR HANDLING MIDDLEWARE
|
|
// ============================================================================
|
|
// This middleware standardizes all error responses per ADR-028.
|
|
// It should be the LAST `app.use()` call to catch all errors.
|
|
// ============================================================================
|
|
|
|
import { Request, Response, NextFunction } from 'express';
|
|
import crypto from 'crypto';
|
|
import { ZodError } from 'zod';
|
|
import {
|
|
ForeignKeyConstraintError,
|
|
NotFoundError,
|
|
UniqueConstraintError,
|
|
ValidationError,
|
|
} from '../services/db/errors.db';
|
|
import { logger } from '../services/logger.server';
|
|
import { ErrorCode, ApiErrorResponse } from '../types/api';
|
|
|
|
/**
|
|
* Helper to send standardized error responses.
|
|
*/
|
|
function sendErrorResponse(
|
|
res: Response,
|
|
statusCode: number,
|
|
code: string,
|
|
message: string,
|
|
details?: unknown,
|
|
meta?: { requestId?: string; timestamp?: string },
|
|
): Response<ApiErrorResponse> {
|
|
const response: ApiErrorResponse = {
|
|
success: false,
|
|
error: {
|
|
code,
|
|
message,
|
|
},
|
|
};
|
|
|
|
if (details !== undefined) {
|
|
response.error.details = details;
|
|
}
|
|
|
|
if (meta) {
|
|
response.meta = meta;
|
|
}
|
|
|
|
return res.status(statusCode).json(response);
|
|
}
|
|
|
|
/**
|
|
* A centralized error handling middleware for the Express application.
|
|
* This middleware should be the LAST `app.use()` call to catch all errors from previous routes and middleware.
|
|
*
|
|
* It standardizes error responses per ADR-028 and ensures consistent logging per ADR-004.
|
|
*/
|
|
export const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
|
|
// If headers have already been sent, delegate to the default Express error handler.
|
|
if (res.headersSent) {
|
|
return next(err);
|
|
}
|
|
|
|
// Use the request-scoped logger if available, otherwise fall back to the global logger.
|
|
const log = req.log || logger;
|
|
|
|
// --- Handle Zod Validation Errors (from validateRequest middleware) ---
|
|
if (err instanceof ZodError) {
|
|
const statusCode = 400;
|
|
const message = 'The request data is invalid.';
|
|
const details = err.issues.map((e) => ({ path: e.path, message: e.message }));
|
|
log.warn(
|
|
{ err, validationErrors: details, statusCode },
|
|
`Client Error on ${req.method} ${req.path}: ${message}`,
|
|
);
|
|
return sendErrorResponse(res, statusCode, ErrorCode.VALIDATION_ERROR, message, details);
|
|
}
|
|
|
|
// --- Handle Custom Operational Errors ---
|
|
if (err instanceof NotFoundError) {
|
|
const statusCode = 404;
|
|
log.warn({ err, statusCode }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
|
|
return sendErrorResponse(res, statusCode, ErrorCode.NOT_FOUND, err.message);
|
|
}
|
|
|
|
if (err instanceof ValidationError) {
|
|
const statusCode = 400;
|
|
log.warn(
|
|
{ err, validationErrors: err.validationErrors, statusCode },
|
|
`Client Error on ${req.method} ${req.path}: ${err.message}`,
|
|
);
|
|
return sendErrorResponse(
|
|
res,
|
|
statusCode,
|
|
ErrorCode.VALIDATION_ERROR,
|
|
err.message,
|
|
err.validationErrors,
|
|
);
|
|
}
|
|
|
|
if (err instanceof UniqueConstraintError) {
|
|
const statusCode = 409;
|
|
log.warn({ err, statusCode }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
|
|
return sendErrorResponse(res, statusCode, ErrorCode.CONFLICT, err.message);
|
|
}
|
|
|
|
if (err instanceof ForeignKeyConstraintError) {
|
|
const statusCode = 400;
|
|
log.warn({ err, statusCode }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
|
|
return sendErrorResponse(res, statusCode, ErrorCode.BAD_REQUEST, err.message);
|
|
}
|
|
|
|
// --- Handle Generic Client Errors (e.g., from express-jwt, or manual status setting) ---
|
|
const errWithStatus = err as Error & { status?: number; statusCode?: number };
|
|
let status = errWithStatus.status || errWithStatus.statusCode;
|
|
// Default UnauthorizedError to 401 if no status is present, a common case for express-jwt.
|
|
if (err.name === 'UnauthorizedError' && !status) {
|
|
status = 401;
|
|
}
|
|
if (status && status >= 400 && status < 500) {
|
|
log.warn(
|
|
{ err, statusCode: status },
|
|
`Client Error on ${req.method} ${req.path}: ${err.message}`,
|
|
);
|
|
|
|
// Map status codes to error codes
|
|
let errorCode: string;
|
|
switch (status) {
|
|
case 400:
|
|
errorCode = ErrorCode.BAD_REQUEST;
|
|
break;
|
|
case 401:
|
|
errorCode = ErrorCode.UNAUTHORIZED;
|
|
break;
|
|
case 403:
|
|
errorCode = ErrorCode.FORBIDDEN;
|
|
break;
|
|
case 404:
|
|
errorCode = ErrorCode.NOT_FOUND;
|
|
break;
|
|
case 409:
|
|
errorCode = ErrorCode.CONFLICT;
|
|
break;
|
|
case 429:
|
|
errorCode = ErrorCode.RATE_LIMITED;
|
|
break;
|
|
default:
|
|
errorCode = ErrorCode.BAD_REQUEST;
|
|
}
|
|
|
|
return sendErrorResponse(res, status, errorCode, err.message);
|
|
}
|
|
|
|
// --- Handle All Other (500-level) Errors ---
|
|
const errorId = crypto.randomBytes(4).toString('hex');
|
|
log.error(
|
|
{
|
|
err,
|
|
errorId,
|
|
req: { method: req.method, url: req.url, headers: req.headers, body: req.body },
|
|
},
|
|
`Unhandled API Error (ID: ${errorId})`,
|
|
);
|
|
|
|
// Also log to console in test/staging environments for visibility in test runners
|
|
if (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'staging') {
|
|
console.error(
|
|
`--- [${process.env.NODE_ENV?.toUpperCase()}] UNHANDLED ERROR (ID: ${errorId}) ---`,
|
|
err,
|
|
);
|
|
}
|
|
|
|
// In production, send a generic message to avoid leaking implementation details.
|
|
if (process.env.NODE_ENV === 'production') {
|
|
return sendErrorResponse(
|
|
res,
|
|
500,
|
|
ErrorCode.INTERNAL_ERROR,
|
|
`An unexpected server error occurred. Please reference error ID: ${errorId}`,
|
|
undefined,
|
|
{ requestId: errorId },
|
|
);
|
|
}
|
|
|
|
// In non-production environments (dev, test, etc.), send more details for easier debugging.
|
|
return sendErrorResponse(
|
|
res,
|
|
500,
|
|
ErrorCode.INTERNAL_ERROR,
|
|
err.message,
|
|
{ stack: err.stack },
|
|
{ requestId: errorId },
|
|
);
|
|
};
|