Timeouts, Retries and Circuit Breakers

Bound every remote call with a deadline, retry only what is safe to repeat, and stop calling a dependency that is already down.

Syntaxclient.send(pattern, data).pipe(timeout(ms), retry({ count, delay }), catchError(fn))

No transporter imposes a deadline of its own. client.send() subscribes and waits, and if the service on the other side never replies it waits for the life of the process. Under load that is how a healthy service falls over: requests arrive faster than replies, each one pins a pending subscription and its payload in memory, and the caller's own callers time out first.

Three operators, caller side

Resilience for request-response lives in the caller's pipe(), not in the service. timeout(ms) errors with a TimeoutError if no value has arrived. retry({ count, delay }) resubscribes - which means it sends the message again. catchError() turns the failure into a fallback value or an error your HTTP layer can map to a status code.

The important subtlety: timeout() unsubscribes the caller. The handler on the other side is already running and runs to completion. A timeout protects your memory and your latency budget; it cancels nothing.

Circuit breakers

A breaker is a small piece of state in front of the call, with three positions. Closed: calls pass, failures are counted. Open: calls are rejected immediately, without touching the network, for a cool-off window. Half-open: one trial call decides whether to close again. Nest ships no breaker, so it is an ordinary provider you write and inject.

Transport knobs

retryAttempts and retryDelay on TCP and Redis are reconnection settings, not per-message retries. On RabbitMQ, prefetchCount with noAck: false bounds work in flight per consumer. On Kafka, unhandled errors in event handlers are retriable by default.

Example

Example · typescript
// src/gateway/billing.client.ts
import { HttpException, HttpStatus, Inject, Injectable, Logger } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { catchError, firstValueFrom, retry, throwError, timeout, TimeoutError } from 'rxjs';

interface Quote {
  cents: number;
  currency: string;
}

@Injectable()
export class BillingClient {
  private readonly logger = new Logger(BillingClient.name);

  constructor(@Inject('BILLING') private readonly client: ClientProxy) {}

  quote(orderId: string): Promise<Quote> {
    return firstValueFrom(
      this.client.send<Quote>({ cmd: 'billing.quote' }, { orderId }).pipe(
        // Without this the subscription waits forever for a reply that may
        // never come. Pick a number smaller than your inbound HTTP timeout.
        timeout(2000),

        // retry() resubscribes, which re-sends the message. Safe here only
        // because 'billing.quote' is a read. The object form of retry needs
        // rxjs 7.3 or newer; NestJS 12 peers rxjs ^7.1.
        retry({ count: 2, delay: 200 }),

        catchError(err => {
          if (err instanceof TimeoutError) {
            this.logger.warn(`billing.quote timed out for order ${orderId}`);
            return throwError(
              () => new HttpException('Billing unavailable', HttpStatus.GATEWAY_TIMEOUT),
            );
          }
          // An RpcException thrown by the service arrives here as its payload
          // object, not as an Error instance.
          return throwError(() => err);
        }),
      ),
    );
  }
}

When to use it

  • Stop a slow billing service from holding every HTTP request open until the load balancer gives up.
  • Return a cached price when the pricing service is unreachable rather than failing the checkout.
  • Keep a consumer from pulling 50,000 queued messages the moment it starts.
  • Explain a wave of timeouts that coincides with a Kafka consumer group rebalance.

More examples

A circuit breaker as a provider

Wrap the call site with breaker.run(() => this.quote(id)) and a dead dependency costs one failed call per cool-off window instead of one timeout per request. One breaker instance per dependency, held as a singleton provider, because the state has to be shared across requests to mean anything.

Example · typescript
// src/resilience/circuit-breaker.ts
import { ServiceUnavailableException } from '@nestjs/common';

type BreakerState = 'closed' | 'open' | 'half-open';

// No @Injectable() here: the constructor takes plain numbers, which the
// injector cannot resolve from types, so this class is registered with a
// factory provider instead (below).
export class CircuitBreaker {
  private state: BreakerState = 'closed';
  private failures = 0;
  private openedAt = 0;

  constructor(
    private readonly threshold: number, // consecutive failures before opening
    private readonly coolOffMs: number, // how long to stay open
  ) {}

  async run<T>(call: () => Promise<T>): Promise<T> {
    if (this.state === 'open') {
      if (Date.now() - this.openedAt < this.coolOffMs) {
        // Fail without touching the network. This is the whole point: the
        // queue of pending calls never forms in the first place.
        throw new ServiceUnavailableException('billing circuit is open');
      }
      this.state = 'half-open'; // let exactly one call through as a probe
    }

    try {
      const result = await call();
      this.failures = 0;
      this.state = 'closed';
      return result;
    } catch (err) {
      this.failures += 1;
      if (this.state === 'half-open' || this.failures >= this.threshold) {
        this.state = 'open';
        this.openedAt = Date.now();
      }
      throw err;
    }
  }
}

// src/billing/billing.module.ts
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { BillingClient } from '../gateway/billing.client.js';
import { CircuitBreaker } from '../resilience/circuit-breaker.js';

@Module({
  imports: [
    ClientsModule.register([
      { name: 'BILLING', transport: Transport.TCP, options: { port: 3001 } },
    ]),
  ],
  providers: [
    BillingClient,
    { provide: CircuitBreaker, useFactory: () => new CircuitBreaker(5, 10_000) },
  ],
  exports: [BillingClient],
})
export class BillingModule {}

RabbitMQ: bound the work in flight and nack what fails

Manual acknowledgement is the difference between a queue that absorbs a failure and one that loses or repeats work. prefetchCount is the knob that keeps a consumer from pulling more than it can process, which is the broker-side equivalent of a timeout on the client side.

Example · typescript
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
    transport: Transport.RMQ,
    options: {
      urls: ['amqp://localhost:5672'],
      queue: 'orders',
      // At most 10 unacknowledged messages per consumer. This is the backpressure
      // valve: without it one consumer will accept the whole backlog at once.
      prefetchCount: 10,
      // noAck: false means Nest does not acknowledge for you, so the handler
      // below must ack or nack every message.
      noAck: false,
      queueOptions: { durable: true },
    },
  });
  await app.listen();
}

bootstrap();

// src/orders/orders.consumer.ts
import { Controller, Logger } from '@nestjs/common';
import { Ctx, EventPattern, Payload, RmqContext } from '@nestjs/microservices';
import { OrdersService } from './orders.service.js';

@Controller()
export class OrdersConsumer {
  private readonly logger = new Logger(OrdersConsumer.name);

  constructor(private readonly orders: OrdersService) {}

  @EventPattern('order.created')
  async handle(@Payload() data: { orderId: string }, @Ctx() ctx: RmqContext) {
    const channel = ctx.getChannelRef();
    const message = ctx.getMessage();

    try {
      await this.orders.provision(data.orderId);
      channel.ack(message);
    } catch (err) {
      this.logger.error(`order.created failed for ${data.orderId}`, err as Error);
      // requeue: false. Requeueing sends it back to the head of the queue and
      // you spin on the same poison message; route it to a dead-letter
      // exchange configured on the queue instead.
      channel.nack(message, false, false);
    }
  }
}

Kafka: retriable failures and connection status

Deciding retriable from permanent is the service's job, because only the service knows which. Get it wrong in the retriable direction and a malformed message is redelivered forever; get it wrong the other way and a transient blip is reported to the caller as a hard failure.

Example · typescript
// src/billing/billing.controller.ts
import { Controller } from '@nestjs/common';
import { KafkaRetriableException, MessagePattern, Payload, RpcException } from '@nestjs/microservices';
import { BillingService } from './billing.service.js';

@Controller()
export class BillingController {
  constructor(private readonly billing: BillingService) {}

  @MessagePattern('billing.quote')
  async quote(@Payload() body: { orderId: string }) {
    try {
      return await this.billing.quote(body.orderId);
    } catch (err) {
      if (err instanceof RangeError) {
        // A permanent failure: reply with an error the caller can read.
        // Retrying this would fail identically every time.
        throw new RpcException({ status: 'error', message: 'unknown order' });
      }
      // A transient failure. KafkaRetriableException bubbles out to kafkajs's
      // eachMessage callback instead of being serialised into a reply, so the
      // broker redelivers the message rather than the caller seeing an error.
      throw new KafkaRetriableException('price service unavailable');
    }
  }
}

// src/gateway/billing.client.ts (excerpt)
// this.client is a ClientKafkaProxy obtained from ClientsModule.
// status emits: 'connected' | 'disconnected' | 'rebalancing' | 'crashed' | 'stopped'
// A 'rebalancing' burst explains a wave of timeouts that is nobody's bug.
this.client.status.subscribe(status => this.logger.log(`kafka ${status}`));

Discussion

  • Be the first to comment on this lesson.