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:
- Circuit is CLOSED - requests go through normally
- Too many failures? Circuit OPENS - stop sending requests
- After a timeout, circuit goes HALF_OPEN - try one request
- 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.
