|

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:

  1. Circuit ist CLOSED - Anfragen gehen normal durch
  2. Zu viele Fehler? Circuit OPENS - keine Anfragen mehr senden
  3. Nach einem Timeout geht der Circuit auf HALF_OPEN - versuche eine Anfrage
  4. 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.