Einen einfachen Circuit Breaker in NestJS bauen
Hattest du schon mal, dass eine externe API ausgefallen ist und deine ganze App mitgerissen hat? Ja, ich auch. Deshalb habe ich einen Circuit Breaker gebaut. Lass mich dir zeigen, wie.
Was ist ein Circuit Breaker?
Denk an die Sicherung in deinem Haus. Wenn etwas schief geht (zu viel Strom), löst sie aus und stoppt den Fluss. Gleiche Idee für APIs.
Wenn ein externer Service anfängt zu versagen:
- Circuit ist CLOSED - Anfragen gehen normal durch
- Zu viele Fehler? Circuit OPENS - keine Anfragen mehr senden
- Nach einem Timeout geht der Circuit auf HALF_OPEN - versuche eine Anfrage
- Wenn es funktioniert, Circuit CLOSES wieder. Wenn nicht, zurück zu OPEN
Das verhindert, dass deine App einen toten Service bombardiert und gibt ihm Zeit zur Erholung.
In NestJS bauen
Fangen wir einfach an - In-Memory Circuit Breaker. Kein Redis, keine externen Abhängigkeiten. Nur TypeScript und NestJS.
Zuerst der 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)
// Prüfe ob Circuit offen ist
if (circuit.state === CircuitState.OPEN) {
if (Date.now() < circuit.nextAttempt) {
throw new Error(`Circuit breaker is OPEN for ${key}`)
}
// Timeout vorbei, versuche 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) {
// Erholt! Schließe den Circuit
circuit.state = CircuitState.CLOSED
circuit.failures = 0
circuit.successes = 0
}
} else {
// Setze Fehlerzähler bei Erfolg zurück
circuit.failures = 0
}
}
private onFailure(key: string, config: CircuitConfig) {
const circuit = this.getCircuit(key)
circuit.failures++
if (circuit.failures >= config.failureThreshold) {
// Zu viele Fehler, öffne den 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)
}
}
In deinem Service verwenden
Jetzt wickle deine externen API-Aufrufe ein:
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 Sekunden
}
)
}
}
Als Decorator machen
Willst du es sauberer? Mach einen 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)
Dann erstelle einen 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)
})
)
}
}
Verwende es so:
@Injectable()
export class PaymentService {
@UseCircuitBreaker({
key: 'payment-api',
failureThreshold: 3,
timeout: 30000
})
async processPayment(amount: number, userId: string) {
// Dein API-Aufruf hier
}
}
Testen
Schreibe Tests, um sicherzustellen, dass es funktioniert:
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')
}
// 5 mal fehlschlagen
for (let i = 0; i < 5; i++) {
try {
await service.execute('test', failingFn, { failureThreshold: 5 })
} catch (e) {}
}
// Circuit sollte jetzt offen sein
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')
}
// Öffne den Circuit
for (let i = 0; i < 5; i++) {
try {
await service.execute('test', failingFn, {
failureThreshold: 5,
timeout: 100
})
} catch (e) {}
}
// Warte auf Timeout
await new Promise(resolve => setTimeout(resolve, 150))
// Sollte eine Anfrage erlauben
const result = await service.execute('test', async () => 'recovered')
expect(result).toBe('recovered')
})
})
Was ich gelernt habe
Halte es zuerst einfach: Fang mit In-Memory an. Füge Redis erst hinzu, wenn du auf mehrere Instanzen skalieren musst.
Tune deine Schwellenwerte: 5 Fehler könnten zu viele oder zu wenige sein. Hängt von deinem Use Case ab. Fang konservativ an, passe basierend auf echten Daten an.
Setze angemessene Timeouts: 1 Minute könnte zu lang sein, wenn der Service sich schnell erholt. Oder zu kurz, wenn er Zeit zum Neustart braucht.
Überwache Circuit-Zustände: Logge, wenn Circuits öffnen/schließen. Du willst wissen, wann Services Probleme haben.
Verwende verschiedene Keys: Nutze nicht einen Circuit für alle externen Aufrufe. Jeder Service sollte seinen eigenen Circuit haben.
Teste Fehlerszenarien: Simuliere API-Fehler in deinen Tests. Stelle sicher, dass der Circuit tatsächlich öffnet.
Fazit
Ein einfacher Circuit Breaker kann deine App vor kaskadierenden Fehlern retten. Diese In-Memory-Version funktioniert für Single-Instance-Apps. Der Code ist unkompliziert, leicht zu verstehen und hat keine externen Abhängigkeiten.
Die Einschränkung? Jede Instanz hat ihren eigenen Circuit-Zustand. Wenn du auf mehrere Instanzen skalierst, brauchst du einen gemeinsamen Zustand. Das gehen wir als Nächstes mit Redis an.
Keep pushing forward and savor every step of your coding journey.
