NestJS error handling: filters, timeouts, and validation
Build a consistent NestJS HTTP error contract with custom exceptions, safe filters, validation, RxJS timeouts, and an active throttling guard.
NestJS maps exceptions from its request lifecycle into HTTP responses. Built-in exceptions express common outcomes such as invalid input or a missing resource; a custom filter can apply a consistent response contract and logging policy. Timeouts and request limits complement that contract, but each needs explicit configuration.
The examples use NestJS HTTP applications and the modern @nestjs/throttler API with named limit definitions. Pin compatible dependency versions. A filter written for HTTP is not automatically a valid GraphQL, WebSocket, or background-job error handler.
Use HTTP exceptions for expected outcomes
Nest provides exceptions including BadRequestException, UnauthorizedException, ForbiddenException, and NotFoundException. Use them when their status matches the operation's semantics.
For a controller backed by an application-defined usersService, validate a numeric route parameter before querying:
import { Get, NotFoundException, Param, ParseIntPipe } from '@nestjs/common';
// Method inside an existing controller with an injected usersService:
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
const user = await this.usersService.findOne(id);
if (!user) throw new NotFoundException('User not found');
return user;
}
ParseIntPipe rejects an invalid integer rather than passing NaN into the service. Add any domain constraint, such as a positive ID, separately. Authentication and object-level authorization still belong in the request path; knowing an ID must not grant access to a private account.
Define stable business error codes
Clients should branch on a stable code, not parse a translated message:
import { HttpException, HttpStatus } from '@nestjs/common';
export class InsufficientBalanceException extends HttpException {
constructor(balance: number, required: number) {
super({
code: 'INSUFFICIENT_BALANCE',
message: 'Insufficient balance',
balance,
required,
}, HttpStatus.UNPROCESSABLE_ENTITY);
}
}
This example retains the source operation's 422 response and balance context. Return those amounts only to a caller authorized to see them. Represent money according to the application's exact decimal or integer-minor-unit policy; this exception class is not a financial calculation implementation.
A domain service may instead raise a transport-independent error and let the HTTP layer map it. Whichever boundary you choose, document which codes the client can recover from.
Build an HTTP exception filter
HttpException.getResponse() can return a string or an object. Validation responses may contain arrays. A filter should inspect those shapes rather than assuming every exception has a .code property.
Save the following as global-exception.filter.ts:
import {
ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
constructor(private readonly adapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost) {
const { httpAdapter } = this.adapterHost;
const ctx = host.switchToHttp();
const request = ctx.getRequest();
const response = ctx.getResponse();
const status = exception instanceof HttpException
? exception.getStatus() : 500;
const raw = exception instanceof HttpException
? exception.getResponse() : undefined;
const detail: Record<string, unknown> =
raw && typeof raw === 'object'
? raw as Record<string, unknown> : {};
const path = String(httpAdapter.getRequestUrl(request)).split('?')[0];
const method = httpAdapter.getRequestMethod(request);
if (status >= 500) {
this.logger.error(
`${method} ${path}`,
exception instanceof Error ? exception.stack : 'Non-Error exception',
);
}
if (httpAdapter.isHeadersSent(response)) return;
const candidate = typeof raw === 'string' ? raw : detail.message;
const publicMessage = typeof candidate === 'string'
|| (Array.isArray(candidate) && candidate.every(v => typeof v === 'string'))
? candidate : 'Request failed';
const body: Record<string, unknown> = {
statusCode: status,
code: status >= 500
? (status === 504 ? 'UPSTREAM_TIMEOUT' : 'INTERNAL_ERROR')
: (typeof detail.code === 'string' ? detail.code : `HTTP_${status}`),
message: status >= 500 ? 'Service unavailable' : publicMessage,
timestamp: new Date().toISOString(),
path,
};
if (status < 500) {
if (Array.isArray(detail.fields)) body.fields = detail.fields;
for (const key of ['balance', 'required']) {
if (typeof detail[key] === 'number') body[key] = detail[key];
}
}
httpAdapter.reply(response, body, status);
}
}
Register it through dependency injection in an application module:
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { GlobalExceptionFilter } from './global-exception.filter';
@Module({
providers: [{ provide: APP_FILTER, useClass: GlobalExceptionFilter }],
})
export class AppModule {}
Merge this provider into the actual module, retaining its controllers and other providers. HttpAdapterHost avoids tying the response write to Express-specific .status().json() calls. See NestJS exception filters.
The example masks messages for all 5xx responses, preserves safe 4xx codes and validation messages, and forwards only selected application fields. Treat those fields as a defined public contract: never put secrets, raw request data, or database details into them. It logs server failures; expected 4xx responses can have a separate, appropriately sampled audit or metric policy.
Removing the query string prevents one common source of token leakage, but path segments and exception messages can also contain sensitive data. Apply redaction to the application's logging pipeline. If headers have already been sent, the filter cannot replace the response; streaming routes need their own lifecycle and cleanup handling.
Await asynchronous work and map downstream timeouts
Nest can handle a rejection from a promise returned by an asynchronous route handler. A detached promise, timer callback, or queue processor is outside that HTTP response lifecycle. Await or return work that belongs to the request and give background work an explicit failure policy.
An HttpService call can turn an RxJS timeout into a known HTTP exception:
import { GatewayTimeoutException } from '@nestjs/common';
import { catchError, firstValueFrom, throwError, timeout, TimeoutError } from 'rxjs';
// Inside an async service method with HttpService injected and a trusted url:
const response = await firstValueFrom(
this.httpService.get(url).pipe(
timeout(5000),
catchError((error: unknown) => {
if (error instanceof TimeoutError) {
return throwError(() => new GatewayTimeoutException('Upstream timed out'));
}
return throwError(() => error);
}),
),
);
return response.data;
Import HttpModule where required by the service. Returning an observable from a controller is another supported approach; merely creating the observable without subscribing or returning it does not execute the intended request flow.
The five-second value is a demonstration deadline, not a universal service objective. Coordinate transport cancellation, connection timeouts, and overall request budgets. A timeout does not prove a downstream write failed, so retries must respect the operation's idempotency contract. See NestJS HTTP client and RxJS timeout.
Enable the throttling guard
Importing ThrottlerModule configures a limiter, but the guard must run to enforce it:
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
@Module({
imports: [ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }])],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}
Again, merge these entries into the same application module, including the filter provider above if used. In this API, ttl is in milliseconds. Override the unnamed/default limit for a route with the matching decorator shape:
import { Throttle } from '@nestjs/throttler';
@Throttle({ default: { limit: 10, ttl: 60_000 } })
Apply the decorator to a controller or route handler. Confirm behavior behind your reverse proxy: trusted proxy settings and client identification determine who shares a limit. The default in-memory storage does not provide one coordinated limit across every replica; use an appropriate shared-storage implementation when that is required. Request throttling is one layer of overload control, not complete DDoS protection. See NestJS rate limiting.
Normalize validation errors without exposing submitted values
In bootstrap code, a global validation pipe can generate the response shape expected by the filter:
import { BadRequestException, ValidationPipe } from '@nestjs/common';
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
exceptionFactory: errors => new BadRequestException({
code: 'VALIDATION_FAILED',
message: 'Validation failed',
fields: errors.map(error => ({
field: error.property,
messages: Object.values(error.constraints ?? {}),
})),
}),
}));
Install compatible class-validator and class-transformer packages and use DTO classes with validation decorators; TypeScript interfaces alone do not provide runtime validation metadata. The field mapping above covers direct DTO constraints. If a DTO uses nested validation, recursively map children to meaningful field paths. Do not return each error's entire target or value, and write constraint messages that do not echo sensitive input. See NestJS validation.
Observe failures by category
Compare validation failures, access denials, missing resources, and upstream timeouts by route and release. Counting every 4xx response as a server defect obscures the distinction between a bad request and a broken service.
Collect structured records through Guance logging and connect them to traces where instrumentation supports it. Node.js APM coverage depends on the SDK, Nest adapter, and underlying library versions; verify custom tasks and message handlers separately. Define monitoring rules around rates and affected operations, and control high-cardinality fields such as raw URLs and user IDs.
Common questions
How do filters and interceptors divide the work?
An interceptor can transform a result or map an error within an observable stream, including translating a timeout. An exception filter decides how a failure becomes the final protocol response. Keep recovery and stream transformations close to the operation; keep the response contract consistent at the boundary.
Can this HTTP filter handle GraphQL or WebSocket failures?
Do not attach it unchanged to those contexts. GraphQL filters return an error appropriate to GraphQL rather than write directly to an HTTP response; WebSocket filters use the WebSocket exception contract. Use protocol-specific filters or deliberate context dispatch. See GraphQL exception filters and WebSocket filters.
Does a global filter catch every application error?
It covers the relevant Nest request lifecycle, not every process failure or independent callback. Test asynchronous rejection, validation, guard failures, and partial responses on the actual routes. Worker failures and process-level shutdown need separate handling.
The examples illustrate response and control-flow behavior, not production load or security testing. For adjacent frameworks, see Express error handling and Next.js error handling, currently in Chinese.
Contact us
Join the community
to join the community
Try Guance
Start online and pay only for what you use.
Get startedChoose a Guance plan