Microservice Exceptions & Retries
Distributed systems fail in ways monoliths never do: a downstream service is briefly unavailable, a network packet is dropped, or a broker redelivers a message. NestJS microservices give you a dedicated exception type (RpcException), an interceptor-based filter model, and RxJS operators to make these failures predictable. This page covers throwing and catching errors across the wire, propagating them cleanly back to callers, and bounding requests with timeout and retry so a slow dependency never cascades into an outage.
Throwing RpcException
In a microservice context you do not throw HttpException. Instead, a message handler throws an RpcException, which the framework serializes and ships back to the caller over the transport. The payload can be a string or a structured object.
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload, RpcException } from '@nestjs/microservices';
@Controller()
export class UsersController {
@MessagePattern({ cmd: 'get_user' })
async getUser(@Payload() id: number) {
if (id <= 0) {
throw new RpcException({
code: 'INVALID_ID',
message: 'User id must be a positive integer',
});
}
const user = await this.repo.findById(id);
if (!user) {
throw new RpcException(`User ${id} not found`);
}
return user;
}
}
When the consumer of this pattern subscribes to the response, the error arrives as a rejected observable, not a thrown synchronous error.
Catching errors on the client
The ClientProxy.send() method returns a cold Observable. A failed RPC emits through the error channel, so you subscribe with an error callback or await firstValueFrom inside a try/catch.
import { Injectable, Inject } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { firstValueFrom } from 'rxjs';
@Injectable()
export class UsersGateway {
constructor(@Inject('USERS_SERVICE') private readonly client: ClientProxy) {}
async fetch(id: number) {
try {
return await firstValueFrom(
this.client.send({ cmd: 'get_user' }, id),
);
} catch (err) {
// err is the serialized RpcException payload
console.error('RPC failed', err);
throw err;
}
}
}
Output:
RPC failed { code: 'INVALID_ID', message: 'User id must be a positive integer' }
Custom exception filters
To normalize the shape of every error a service emits, register an @Catch() filter bound to the RPC host type. Returning an observable lets you transform the error before it leaves the service.
import { Catch, RpcExceptionFilter, ArgumentsHost } from '@nestjs/common';
import { Observable, throwError } from 'rxjs';
import { RpcException } from '@nestjs/microservices';
@Catch(RpcException)
export class AllRpcExceptionsFilter implements RpcExceptionFilter<RpcException> {
catch(exception: RpcException, _host: ArgumentsHost): Observable<any> {
const error = exception.getError();
return throwError(() => ({
status: 'error',
error: typeof error === 'string' ? { message: error } : error,
timestamp: new Date().toISOString(),
}));
}
}
Apply it globally in the microservice bootstrap:
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';
import { AllRpcExceptionsFilter } from './all-rpc-exceptions.filter';
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
AppModule,
{ transport: Transport.TCP },
);
app.useGlobalFilters(new AllRpcExceptionsFilter());
await app.listen();
}
bootstrap();
Tip: a non-
RpcExceptionthrown inside a message handler is wrapped automatically, but its message becomes a generic"Internal server error". ThrowRpcExceptionexplicitly whenever you want the caller to see a meaningful payload.
Timeouts and retries with RxJS
Because send() is an observable, you compose resilience with standard RxJS operators. The timeout operator caps how long the caller waits; retry re-subscribes (which re-sends the message) on failure.
import { Inject, Injectable } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { firstValueFrom, throwError, timer } from 'rxjs';
import { catchError, retry, timeout } from 'rxjs/operators';
@Injectable()
export class ResilientGateway {
constructor(@Inject('PAYMENTS') private readonly client: ClientProxy) {}
charge(orderId: string, amount: number) {
return firstValueFrom(
this.client.send({ cmd: 'charge' }, { orderId, amount }).pipe(
timeout(3000),
retry({
count: 3,
delay: (_err, attempt) => timer(Math.pow(2, attempt) * 100),
}),
catchError((err) =>
throwError(() => new Error(`charge failed: ${err.message}`)),
),
),
);
}
}
This issues up to four attempts (initial + 3 retries) with exponential backoff (200ms, 400ms, 800ms), aborting any attempt that exceeds 3 seconds.
| Operator | Purpose | Key option |
|---|---|---|
timeout(ms) | Fail fast if no response arrives | milliseconds before TimeoutError |
retry({ count, delay }) | Re-send on failure | count, backoff delay function |
catchError(fn) | Translate or recover from errors | returns a fallback observable |
defaultIfEmpty(v) | Supply a value when stream is empty | the fallback value |
Designing idempotent handlers
Retries — whether yours or a broker’s automatic redelivery — mean a handler may run more than once for the same logical request. Make write operations idempotent so duplicates are harmless. A common pattern is a dedupe key persisted with a unique constraint.
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { ChargeDto } from './charge.dto';
@Controller()
export class PaymentsController {
@MessagePattern({ cmd: 'charge' })
async charge(@Payload() dto: ChargeDto) {
const existing = await this.payments.findByOrderId(dto.orderId);
if (existing) {
return existing; // already processed — return prior result
}
return this.payments.create(dto); // orderId has a UNIQUE index
}
}
If two retried messages race, the unique index rejects the second insert; catch that violation and return the existing record rather than surfacing an error.
Best Practices
- Throw
RpcException(notHttpException) inside microservice handlers, and prefer a structured{ code, message }payload over a bare string. - Register a global
RpcExceptionFilterso every service emits errors in a consistent, machine-readable shape. - Always pair
send()withtimeout— an unbounded RPC can hang a caller indefinitely when a dependency stalls. - Use exponential backoff in
retryand cap the attempt count; uncapped retries amplify load during an outage. - Only retry idempotent operations; guard writes with a dedupe key and a unique constraint.
- Translate downstream errors at the gateway with
catchErrorso HTTP clients receive meaningful status codes, not raw RPC payloads.