|

Adding Redis to Your Circuit Breaker in NestJS

Last month we built a simple in-memory circuit breaker. Works great… until you scale to multiple instances. Then each instance has its own circuit state. Not ideal.

Let’s fix that with Redis.

The problem with in-memory

You’ve got 3 app instances. External API starts failing.

  • Instance 1: Opens circuit after 5 failures
  • Instance 2: Opens circuit after 5 failures
  • Instance 3: Opens circuit after 5 failures

That’s 15 failed requests before all circuits open. Plus, if Instance 1’s circuit goes half-open and succeeds, Instances 2 and 3 don’t know about it.

We need shared state. Enter Redis.

Adding Redis

First, install dependencies:

npm install ioredis
npm install -D @types/ioredis

Create a Redis module:

import { Module, Global } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import Redis from 'ioredis'

export const REDIS_CLIENT = 'REDIS_CLIENT'

@Global()
@Module({
  providers: [
    {
      provide: REDIS_CLIENT,
      useFactory: (configService: ConfigService) => {
        return new Redis({
          host: configService.get('REDIS_HOST', 'localhost'),
          port: configService.get('REDIS_PORT', 6379),
          password: configService.get('REDIS_PASSWORD'),
          retryStrategy: (times) => {
            const delay = Math.min(times * 50, 2000)
            return delay
          }
        })
      },
      inject: [ConfigService]
    }
  ],
  exports: [REDIS_CLIENT]
})
export class RedisModule {}

Redis-backed circuit breaker

Now update the circuit breaker to use Redis:

import { Injectable, Inject } from '@nestjs/common'
import Redis from 'ioredis'
import { REDIS_CLIENT } from './redis.module'

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

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

@Injectable()
export class CircuitBreakerService {
  private readonly defaultConfig: CircuitConfig = {
    failureThreshold: 5,
    successThreshold: 2,
    timeout: 60000
  }

  constructor(
    @Inject(REDIS_CLIENT) private readonly redis: Redis
  ) {}

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

    if (state === CircuitState.OPEN) {
      const nextAttempt = await this.redis.get(`circuit:${key}:next_attempt`)
      
      if (nextAttempt && Date.now() < parseInt(nextAttempt)) {
        throw new Error(`Circuit breaker is OPEN for ${key}`)
      }

      // Timeout passed, try half-open
      await this.setState(key, CircuitState.HALF_OPEN)
      await this.redis.set(`circuit:${key}:successes`, 0)
    }

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

  private async getState(key: string): Promise<CircuitState> {
    const state = await this.redis.get(`circuit:${key}:state`)
    return (state as CircuitState) || CircuitState.CLOSED
  }

  private async setState(key: string, state: CircuitState) {
    await this.redis.set(`circuit:${key}:state`, state)
  }

  private async onSuccess(key: string, config: CircuitConfig) {
    const state = await this.getState(key)

    if (state === CircuitState.HALF_OPEN) {
      const successes = await this.redis.incr(`circuit:${key}:successes`)

      if (successes >= config.successThreshold) {
        // Recovered! Close the circuit
        await this.setState(key, CircuitState.CLOSED)
        await this.redis.del(
          `circuit:${key}:failures`,
          `circuit:${key}:successes`,
          `circuit:${key}:next_attempt`
        )
      }
    } else {
      // Reset failure count on success
      await this.redis.set(`circuit:${key}:failures`, 0)
    }
  }

  private async onFailure(key: string, config: CircuitConfig) {
    const failures = await this.redis.incr(`circuit:${key}:failures`)

    if (failures >= config.failureThreshold) {
      // Too many failures, open the circuit
      await this.setState(key, CircuitState.OPEN)
      const nextAttempt = Date.now() + config.timeout
      await this.redis.set(`circuit:${key}:next_attempt`, nextAttempt)
      await this.redis.set(`circuit:${key}:successes`, 0)
    }
  }

  async reset(key: string) {
    await this.redis.del(
      `circuit:${key}:state`,
      `circuit:${key}:failures`,
      `circuit:${key}:successes`,
      `circuit:${key}:next_attempt`
    )
  }

  async getStats(key: string) {
    const [state, failures, successes, nextAttempt] = await Promise.all([
      this.redis.get(`circuit:${key}:state`),
      this.redis.get(`circuit:${key}:failures`),
      this.redis.get(`circuit:${key}:successes`),
      this.redis.get(`circuit:${key}:next_attempt`)
    ])

    return {
      state: state || CircuitState.CLOSED,
      failures: parseInt(failures || '0'),
      successes: parseInt(successes || '0'),
      nextAttempt: nextAttempt ? parseInt(nextAttempt) : null
    }
  }
}

Handling Redis failures

Here’s the thing: what if Redis goes down? Your circuit breaker shouldn’t break your app.

Add a fallback:

@Injectable()
export class CircuitBreakerService {
  private fallbackCircuits = new Map<string, any>()
  private redisAvailable = true

  constructor(
    @Inject(REDIS_CLIENT) private readonly redis: Redis
  ) {
    // Monitor Redis connection
    this.redis.on('error', () => {
      this.redisAvailable = false
    })

    this.redis.on('connect', () => {
      this.redisAvailable = true
    })
  }

  async execute<T>(
    key: string,
    fn: () => Promise<T>,
    config?: Partial<CircuitConfig>
  ): Promise<T> {
    // If Redis is down, use in-memory fallback
    if (!this.redisAvailable) {
      return this.executeWithFallback(key, fn, config)
    }

    try {
      return await this.executeWithRedis(key, fn, config)
    } catch (error) {
      // Redis operation failed, use fallback
      if (error.message?.includes('Redis')) {
        return this.executeWithFallback(key, fn, config)
      }
      throw error
    }
  }

  private async executeWithRedis<T>(
    key: string,
    fn: () => Promise<T>,
    config?: Partial<CircuitConfig>
  ): Promise<T> {
    // Redis implementation from above
    // ...
  }

  private async executeWithFallback<T>(
    key: string,
    fn: () => Promise<T>,
    config?: Partial<CircuitConfig>
  ): Promise<T> {
    // Use in-memory Map as fallback
    if (!this.fallbackCircuits.has(key)) {
      this.fallbackCircuits.set(key, {
        state: CircuitState.CLOSED,
        failures: 0,
        successes: 0,
        nextAttempt: 0
      })
    }

    const circuit = this.fallbackCircuits.get(key)
    const cfg = { ...this.defaultConfig, ...config }

    // Same logic as in-memory version
    if (circuit.state === CircuitState.OPEN) {
      if (Date.now() < circuit.nextAttempt) {
        throw new Error(`Circuit breaker is OPEN for ${key}`)
      }
      circuit.state = CircuitState.HALF_OPEN
      circuit.successes = 0
    }

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

  private onSuccessFallback(key: string, config: CircuitConfig) {
    const circuit = this.fallbackCircuits.get(key)

    if (circuit.state === CircuitState.HALF_OPEN) {
      circuit.successes++
      if (circuit.successes >= config.successThreshold) {
        circuit.state = CircuitState.CLOSED
        circuit.failures = 0
        circuit.successes = 0
      }
    } else {
      circuit.failures = 0
    }
  }

  private onFailureFallback(key: string, config: CircuitConfig) {
    const circuit = this.fallbackCircuits.get(key)
    circuit.failures++

    if (circuit.failures >= config.failureThreshold) {
      circuit.state = CircuitState.OPEN
      circuit.nextAttempt = Date.now() + config.timeout
      circuit.successes = 0
    }
  }
}

Monitoring circuit states

Add an endpoint to check circuit health:

@Controller('health')
export class HealthController {
  constructor(
    private readonly circuitBreaker: CircuitBreakerService
  ) {}

  @Get('circuits')
  async getCircuits() {
    const circuits = ['payment-api', 'email-service', 'analytics-api']
    
    const stats = await Promise.all(
      circuits.map(async (key) => ({
        key,
        ...(await this.circuitBreaker.getStats(key))
      }))
    )

    return stats
  }

  @Post('circuits/:key/reset')
  async resetCircuit(@Param('key') key: string) {
    await this.circuitBreaker.reset(key)
    return { message: `Circuit ${key} reset` }
  }
}

What I learned

Redis makes it distributed: All instances share the same circuit state.

Always have a fallback: If Redis goes down, fall back to in-memory. Don’t let your circuit breaker break your app.

Monitor Redis health: Track connection status and switch to fallback automatically.

Use Redis pipelining: When getting multiple values, use Promise.all to batch Redis calls.

Set TTLs on keys: Add expiration to circuit keys so they don’t live forever.

Test Redis failures: Simulate Redis going down in your tests.

Wrapping up

Redis-backed circuit breakers work across multiple instances. But we can do better - next post, we’ll make it hybrid: use in-memory until Redis connects, then sync state.

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