|

Building a Simple Circuit Breaker in NestJS

Ever had a third-party API go down and take your entire app with it? Yeah, me too. That’s why I built a circuit breaker. Let me show you how.

What’s a circuit breaker?

Think of it like the circuit breaker in your house. When something goes wrong (too much current), it trips and stops the flow. Same idea for APIs.

When an external service starts failing:

  1. Circuit is CLOSED - requests go through normally
  2. Too many failures? Circuit OPENS - stop sending requests
  3. After a timeout, circuit goes HALF_OPEN - try one request
  4. If it works, circuit CLOSES again. If not, back to OPEN

This prevents your app from hammering a dead service and gives it time to recover.

Building it in NestJS

Let’s start simple - in-memory circuit breaker. No Redis, no external dependencies. Just TypeScript and NestJS.

First, the circuit breaker service:

import { Injectable } from '@nestjs/common'

enum CircuitState {
  CLOSED = 'CLOSED',
  OPEN = 'OPEN',
  HALF_OPEN = 'HALF_OPEN'
}

interface CircuitConfig {
  failureThreshold: number
  successThreshold: number
  timeout: number
}

interface CircuitStats {
  state: CircuitState
  failures: number
  successes: number
  nextAttempt: number
}

@Injectable()
export class CircuitBreakerService {
  private circuits = new Map<string, CircuitStats>()
  private readonly defaultConfig: CircuitConfig = {
    failureThreshold: 5,
    successThreshold: 2,
    timeout: 60000 // 1 minute
  }

  async execute<T>(
    key: string,
    fn: () => Promise<T>,
    config?: Partial<CircuitConfig>
  ): Promise<T> {
    const cfg = { ...this.defaultConfig, ...config }
    const circuit = this.getCircuit(key)

    // Check if circuit is open
    if (circuit.state === CircuitState.OPEN) {
      if (Date.now() < circuit.nextAttempt) {
        throw new Error(`Circuit breaker is OPEN for ${key}`)
      }
      // Timeout passed, try half-open
      circuit.state = CircuitState.HALF_OPEN
      circuit.successes = 0
    }

    try {
      const result = await fn()
      this.onSuccess(key, cfg)
      return result
    } catch (error) {
      this.onFailure(key, cfg)
      throw error
    }
  }

  private getCircuit(key: string): CircuitStats {
    if (!this.circuits.has(key)) {
      this.circuits.set(key, {
        state: CircuitState.CLOSED,
        failures: 0,
        successes: 0,
        nextAttempt: 0
      })
    }
    return this.circuits.get(key)!
  }

  private onSuccess(key: string, config: CircuitConfig) {
    const circuit = this.getCircuit(key)

    if (circuit.state === CircuitState.HALF_OPEN) {
      circuit.successes++
      
      if (circuit.successes >= config.successThreshold) {
        // Recovered! Close the circuit
        circuit.state = CircuitState.CLOSED
        circuit.failures = 0
        circuit.successes = 0
      }
    } else {
      // Reset failure count on success
      circuit.failures = 0
    }
  }

  private onFailure(key: string, config: CircuitConfig) {
    const circuit = this.getCircuit(key)
    circuit.failures++

    if (circuit.failures >= config.failureThreshold) {
      // Too many failures, open the circuit
      circuit.state = CircuitState.OPEN
      circuit.nextAttempt = Date.now() + config.timeout
      circuit.successes = 0
    }
  }

  getState(key: string): CircuitState {
    return this.getCircuit(key).state
  }

  reset(key: string) {
    this.circuits.delete(key)
  }
}

Using it in your service

Now wrap your external API calls:

import { Injectable } from '@nestjs/common'
import { HttpService } from '@nestjs/axios'
import { CircuitBreakerService } from './circuit-breaker.service'
import { firstValueFrom } from 'rxjs'

@Injectable()
export class PaymentService {
  constructor(
    private readonly httpService: HttpService,
    private readonly circuitBreaker: CircuitBreakerService
  ) {}

  async processPayment(amount: number, userId: string) {
    return this.circuitBreaker.execute(
      'payment-api',
      async () => {
        const response = await firstValueFrom(
          this.httpService.post('https://payment-api.com/charge', {
            amount,
            userId
          })
        )
        return response.data
      },
      {
        failureThreshold: 3,
        successThreshold: 2,
        timeout: 30000 // 30 seconds
      }
    )
  }
}

Making it a decorator

Want it cleaner? Make a decorator:

import { SetMetadata } from '@nestjs/common'

export const CIRCUIT_BREAKER_KEY = 'circuit_breaker'

export interface CircuitBreakerOptions {
  key: string
  failureThreshold?: number
  successThreshold?: number
  timeout?: number
}

export const UseCircuitBreaker = (options: CircuitBreakerOptions) =>
  SetMetadata(CIRCUIT_BREAKER_KEY, options)

Then create an interceptor:

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler
} from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { Observable, throwError } from 'rxjs'
import { catchError, tap } from 'rxjs/operators'
import { CircuitBreakerService } from './circuit-breaker.service'
import { CIRCUIT_BREAKER_KEY, CircuitBreakerOptions } from './circuit-breaker.decorator'

@Injectable()
export class CircuitBreakerInterceptor implements NestInterceptor {
  constructor(
    private readonly reflector: Reflector,
    private readonly circuitBreaker: CircuitBreakerService
  ) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const options = this.reflector.get<CircuitBreakerOptions>(
      CIRCUIT_BREAKER_KEY,
      context.getHandler()
    )

    if (!options) {
      return next.handle()
    }

    const circuit = this.circuitBreaker.getCircuit(options.key)

    if (circuit.state === 'OPEN' && Date.now() < circuit.nextAttempt) {
      return throwError(() => new Error(`Circuit breaker is OPEN for ${options.key}`))
    }

    return next.handle().pipe(
      tap(() => {
        this.circuitBreaker.onSuccess(options.key, options)
      }),
      catchError((error) => {
        this.circuitBreaker.onFailure(options.key, options)
        return throwError(() => error)
      })
    )
  }
}

Use it like this:

@Injectable()
export class PaymentService {
  @UseCircuitBreaker({
    key: 'payment-api',
    failureThreshold: 3,
    timeout: 30000
  })
  async processPayment(amount: number, userId: string) {
    // Your API call here
  }
}

Testing it

Write tests to make sure it works:

describe('CircuitBreakerService', () => {
  let service: CircuitBreakerService

  beforeEach(() => {
    service = new CircuitBreakerService()
  })

  it('should allow requests when circuit is closed', async () => {
    const result = await service.execute('test', async () => 'success')
    expect(result).toBe('success')
  })

  it('should open circuit after threshold failures', async () => {
    const failingFn = async () => {
      throw new Error('API down')
    }

    // Fail 5 times
    for (let i = 0; i < 5; i++) {
      try {
        await service.execute('test', failingFn, { failureThreshold: 5 })
      } catch (e) {}
    }

    // Circuit should be open now
    await expect(
      service.execute('test', async () => 'success')
    ).rejects.toThrow('Circuit breaker is OPEN')
  })

  it('should transition to half-open after timeout', async () => {
    const failingFn = async () => {
      throw new Error('API down')
    }

    // Open the circuit
    for (let i = 0; i < 5; i++) {
      try {
        await service.execute('test', failingFn, {
          failureThreshold: 5,
          timeout: 100
        })
      } catch (e) {}
    }

    // Wait for timeout
    await new Promise(resolve => setTimeout(resolve, 150))

    // Should allow one request
    const result = await service.execute('test', async () => 'recovered')
    expect(result).toBe('recovered')
  })
})

What I learned

Keep it simple first: Start with in-memory. Don’t add Redis until you need to scale to multiple instances.

Tune your thresholds: 5 failures might be too many or too few. Depends on your use case. Start conservative, adjust based on real data.

Set appropriate timeouts: 1 minute might be too long if the service recovers quickly. Or too short if it needs time to restart.

Monitor circuit states: Log when circuits open/close. You want to know when services are having issues.

Use different keys: Don’t use one circuit for all external calls. Each service should have its own circuit.

Test failure scenarios: Simulate API failures in your tests. Make sure the circuit actually opens.

Wrapping up

A simple circuit breaker can save your app from cascading failures. This in-memory version works for single-instance apps. The code is straightforward, easy to understand, and has no external dependencies.

The limitation? Each instance has its own circuit state. If you scale to multiple instances, you’ll need shared state. That’s what we’ll tackle next with Redis.

Keep pushing forward and savor every step of your coding journey.