Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/common/errors/app.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { HttpException, HttpStatus } from "@nestjs/common";
import { ErrorCode } from "./error-codes";

export interface ErrorResponse {
statusCode: number;
errorCode: ErrorCode;
message: string | object;
correlationId: string;
timestamp: string;
path: string;
errors?: Record<string, string[]>;
}

export class AppException extends HttpException {
public readonly errorCode: ErrorCode;

constructor(
message: string,
status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode = ErrorCode.INTERNAL_ERROR,
) {
super(message, status);
this.errorCode = errorCode;
}
}

export class NotFoundException extends AppException {
constructor(message = "Resource not found") {
super(message, HttpStatus.NOT_FOUND, ErrorCode.NOT_FOUND);
}
}

export class UnauthorizedException extends AppException {
constructor(message = "Unauthorized") {
super(message, HttpStatus.UNAUTHORIZED, ErrorCode.UNAUTHORIZED);
}
}

export class ForbiddenException extends AppException {
constructor(message = "Forbidden") {
super(message, HttpStatus.FORBIDDEN, ErrorCode.FORBIDDEN);
}
}

export class BadRequestException extends AppException {
constructor(message = "Bad request") {
super(message, HttpStatus.BAD_REQUEST, ErrorCode.VALIDATION_ERROR);
}
}

export class ConflictException extends AppException {
constructor(message = "Resource conflict") {
super(message, HttpStatus.CONFLICT, ErrorCode.CONFLICT);
}
}

export class RateLimitException extends AppException {
constructor(message = "Too many requests") {
super(message, HttpStatus.TOO_MANY_REQUESTS, ErrorCode.RATE_LIMITED);
}
}
20 changes: 20 additions & 0 deletions src/common/errors/error-codes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export enum ErrorCode {
// Generic
INTERNAL_ERROR = "INTERNAL_ERROR",
NOT_FOUND = "NOT_FOUND",
VALIDATION_ERROR = "VALIDATION_ERROR",
RATE_LIMITED = "RATE_LIMITED",

// Auth
UNAUTHORIZED = "UNAUTHORIZED",
FORBIDDEN = "FORBIDDEN",
TOKEN_EXPIRED = "TOKEN_EXPIRED",
INVALID_CREDENTIALS = "INVALID_CREDENTIALS",
ACCOUNT_LOCKED = "ACCOUNT_LOCKED",

// Business
CONFLICT = "CONFLICT",
RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND",
DUPLICATE_ENTRY = "DUPLICATE_ENTRY",
PRECONDITION_FAILED = "PRECONDITION_FAILED",
}
159 changes: 110 additions & 49 deletions src/common/filters/global-exception.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ import {
import { ConfigService } from "@nestjs/config";
import { Request, Response } from "express";
import { v4 as uuidv4 } from "uuid";
import { AppException } from "../errors/app.exception";
import { ErrorCode } from "../errors/error-codes";

interface ValidationConstraints {
[property: string]: string[];
}

/**
* Global exception filter that sanitizes error responses in production.
* - Never exposes stack traces or internal details to clients.
* - Logs full error details server-side with a correlation ID.
* - Returns structured, generic error responses to clients.
*/
@Catch()
@Injectable()
export class GlobalExceptionFilter implements ExceptionFilter {
Expand All @@ -35,52 +35,124 @@ export class GlobalExceptionFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();

const correlationId = uuidv4();
const correlationId =
(request.headers["x-request-id"] as string) || uuidv4();

let status = HttpStatus.INTERNAL_SERVER_ERROR;
let clientMessage: string | object = "An unexpected error occurred";
let errorCode: ErrorCode = ErrorCode.INTERNAL_ERROR;
let errors: ValidationConstraints | undefined;

if (exception instanceof HttpException) {
if (exception instanceof AppException) {
status = exception.getStatus();
errorCode = exception.errorCode;
clientMessage = this.extractMessage(exception);
} else if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();

// In production, only expose safe HTTP exception messages
if (this.isProduction) {
clientMessage =
typeof exceptionResponse === "string"
? exceptionResponse
: ((exceptionResponse as any).message ?? "Request failed");
if (
status === HttpStatus.BAD_REQUEST &&
typeof exceptionResponse === "object"
) {
const response = exceptionResponse as any;
if (Array.isArray(response.message)) {
errors = this.formatValidationErrors(response.message);
clientMessage = "Validation failed";
errorCode = ErrorCode.VALIDATION_ERROR;
} else {
clientMessage = this.isProduction
? (response.message ?? "Request failed")
: exceptionResponse;
errorCode = this.mapStatusToCode(status);
}
} else {
clientMessage =
typeof exceptionResponse === "string"
clientMessage = this.isProduction
? typeof exceptionResponse === "string"
? exceptionResponse
: exceptionResponse;
: ((exceptionResponse as any).message ?? "Request failed")
: exceptionResponse;
errorCode = this.mapStatusToCode(status);
}
}

// Report production-grade errors to Sentry for grouping, breadcrumbs, and alerting.
if (Sentry.getCurrentHub().getClient()) {
Sentry.withScope((scope) => {
scope.setTag("http.method", request.method);
scope.setTag("http.status_code", String(status));
scope.setTag("correlation_id", correlationId);
scope.setExtra("path", request.url);
scope.setExtra("query", request.query);
scope.setExtra("params", request.params);
scope.setUser({ ip_address: request.ip });

const level: SeverityLevel = status >= 500 ? "error" : "warning";
scope.setLevel(level);

const errorToCapture =
exception instanceof Error ? exception : new Error(String(exception));
if (status >= 500 || status === 401 || status === 403) {
Sentry.captureException(errorToCapture);
}
});
this.reportToSentry(exception, status, correlationId, request);
this.logError(exception, status, correlationId, request);

response.status(status).json({
statusCode: status,
errorCode,
message: clientMessage,
...(errors && { errors }),
correlationId,
timestamp: new Date().toISOString(),
path: request.url,
});
}

private extractMessage(exception: HttpException): string {
const response = exception.getResponse();
if (typeof response === "string") return response;
return (response as any).message ?? "Request failed";
}

private formatValidationErrors(messages: string[]): ValidationConstraints {
const errors: ValidationConstraints = {};
for (const msg of messages) {
const match = msg.match(/^(\w+)\s/);
const field = match ? match[1] : "body";
if (!errors[field]) errors[field] = [];
errors[field].push(msg);
}
return errors;
}

// Log full details server-side only
private mapStatusToCode(status: number): ErrorCode {
const map: Record<number, ErrorCode> = {
400: ErrorCode.VALIDATION_ERROR,
401: ErrorCode.UNAUTHORIZED,
403: ErrorCode.FORBIDDEN,
404: ErrorCode.NOT_FOUND,
409: ErrorCode.CONFLICT,
429: ErrorCode.RATE_LIMITED,
};
return map[status] ?? ErrorCode.INTERNAL_ERROR;
}

private reportToSentry(
exception: unknown,
status: number,
correlationId: string,
request: Request,
): void {
if (!Sentry.getCurrentHub().getClient()) return;

Sentry.withScope((scope) => {
scope.setTag("http.method", request.method);
scope.setTag("http.status_code", String(status));
scope.setTag("correlation_id", correlationId);
scope.setExtra("path", request.url);
scope.setExtra("query", request.query);
scope.setExtra("params", request.params);
scope.setUser({ ip_address: request.ip });

const level: SeverityLevel = status >= 500 ? "error" : "warning";
scope.setLevel(level);

const errorToCapture =
exception instanceof Error ? exception : new Error(String(exception));
if (status >= 500 || status === 401 || status === 403) {
Sentry.captureException(errorToCapture);
}
});
}

private logError(
exception: unknown,
status: number,
correlationId: string,
request: Request,
): void {
this.logger.error({
correlationId,
method: request.method,
Expand All @@ -95,16 +167,5 @@ export class GlobalExceptionFilter implements ExceptionFilter {
}
: String(exception),
});

response.status(status).json({
statusCode: status,
correlationId,
message: clientMessage,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}



13 changes: 13 additions & 0 deletions src/common/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
// Errors
export { ErrorCode } from "./errors/error-codes";
export {
AppException,
NotFoundException,
UnauthorizedException,
ForbiddenException,
BadRequestException,
ConflictException,
RateLimitException,
} from "./errors/app.exception";
export type { ErrorResponse } from "./errors/app.exception";

// Guards
export { RolesGuard } from "./guard/roles.guard";
export { KycGuard } from "./guard/kyc.guard";
Expand Down
Loading