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.
