Vinicius Aguiar
Arquitectura

Circuit Breaker en Node.js: protegiendo sistemas contra fallas en cascada

16 de abr. de 2026 · 9 min de lectura

Cuando tu aplicación depende de APIs externas — proveedores de pago, marketplaces, servicios de envío — estás aceptando que parte de tu sistema está fuera de tu control. Estas dependencias pueden volverse lentas, retornar errores o simplemente dejar de responder. Sin protección, una API externa inestable puede tumbar todo tu sistema. El Circuit Breaker es el patrón que evita esto.

El problema: fallas en cascada

Imagina un escenario real: tu aplicación hace una llamada a la API de un marketplace durante el checkout. Normalmente esta llamada toma 200ms. Pero el marketplace tiene problemas y empieza a demorar 30 segundos en responder — o simplemente no responde.

Lo que sucede sin protección:

  1. El checkout del usuario se cuelga esperando la respuesta del marketplace
  2. Mientras espera, nuevas solicitudes llegan y también quedan trabadas
  3. El pool de conexiones de tu servidor se agota
  4. Tu servidor deja de responder a todos los usuarios — no solo a los que dependen del marketplace
  5. El sistema entero cae por causa de una dependencia externa

Esto es una falla en cascada. Una dependencia inestable propaga la falla a todo el sistema. El Circuit Breaker interrumpe esta propagación.

Cómo funciona el Circuit Breaker

El patrón funciona como un disyuntor eléctrico. Monitorea las llamadas a una dependencia externa y tiene tres estados:

  • CLOSED (cerrado) — estado normal. Las solicitudes pasan a la API externa. Si las fallas se acumulan más allá del threshold, transiciona a OPEN.
  • OPEN (abierto) — estado de protección. Las solicitudes no van a la API. Retorna fallback inmediatamente. Después de un período de cooldown, transiciona a HALF-OPEN.
  • HALF-OPEN (medio abierto) — estado de prueba. Permite una solicitud como prueba. Si tiene éxito, vuelve a CLOSED. Si falla, vuelve a OPEN.
Diagrama de la state machine del Circuit Breaker — estados CLOSED, OPEN y HALF-OPEN con transicionesDiagrama de la state machine del Circuit Breaker — estados CLOSED, OPEN y HALF-OPEN con transiciones

Implementación en TypeScript

Voy a implementar un Circuit Breaker genérico que puede envolver cualquier llamada externa. La idea es que sea reutilizable para diferentes dependencias.

type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN'

interface CircuitBreakerOptions {
  failureThreshold: number  // fallas antes de abrir
  cooldownMs: number        // tiempo en OPEN antes de probar
  timeoutMs: number         // timeout por solicitud
}

class CircuitBreaker {
  private state: CircuitState = 'CLOSED'
  private failureCount = 0
  private lastFailureTime = 0
  private readonly options: CircuitBreakerOptions

  constructor(options: Partial<CircuitBreakerOptions> = {}) {
    this.options = {
      failureThreshold: options.failureThreshold ?? 5,
      cooldownMs: options.cooldownMs ?? 60_000,
      timeoutMs: options.timeoutMs ?? 10_000,
    }
  }

  async execute<T>(fn: () => Promise<T>, fallback: () => T): Promise<T> {
    if (this.state === 'OPEN') {
      if (Date.now() - this.lastFailureTime >= this.options.cooldownMs) {
        this.state = 'HALF_OPEN'
      } else {
        return fallback()
      }
    }

    try {
      const result = await this.withTimeout(fn())
      this.onSuccess()
      return result
    } catch (error) {
      this.onFailure()
      return fallback()
    }
  }

  private onSuccess() {
    this.failureCount = 0
    this.state = 'CLOSED'
  }

  private onFailure() {
    this.failureCount++
    this.lastFailureTime = Date.now()

    if (this.failureCount >= this.options.failureThreshold) {
      this.state = 'OPEN'
    }
  }

  private withTimeout<T>(promise: Promise<T>): Promise<T> {
    return Promise.race([
      promise,
      new Promise<never>((_, reject) =>
        setTimeout(() => reject(new Error('Timeout')), this.options.timeoutMs)
      ),
    ])
  }

  getState(): CircuitState {
    return this.state
  }
}

Uso práctico: protegiendo una llamada de API

Con la clase creada, envolver cualquier llamada externa es simple:

// Un circuit breaker por dependencia
const marketplaceBreaker = new CircuitBreaker({
  failureThreshold: 3,   // abre después de 3 fallas
  cooldownMs: 30_000,    // espera 30s antes de probar
  timeoutMs: 5_000,      // timeout de 5s por llamada
})

const paymentBreaker = new CircuitBreaker({
  failureThreshold: 2,   // más sensible — es pago
  cooldownMs: 60_000,    // cooldown mayor
  timeoutMs: 10_000,     // timeout mayor — proveedores de pago son lentos
})

// Llamada protegida
async function getProductFromMarketplace(productId: string) {
  return marketplaceBreaker.execute(
    // Llamada real
    () => fetch(`https://api.marketplace.com/products/${productId}`)
      .then(res => res.json()),
    // Fallback cuando el circuit está abierto
    () => getCachedProduct(productId)
  )
}

El punto importante: cada dependencia externa debe tener su propio Circuit Breaker. Si el marketplace cae, el circuit del marketplace se abre — pero el circuit del proveedor de pago sigue cerrado y funcionando normalmente.

Estrategias de fallback

El fallback es lo que tu sistema retorna cuando el circuit está abierto. Esta es la parte que requiere más pensamiento de producto, porque el fallback necesita ser lo suficientemente útil para que el usuario no perciba la degradación. Algunas estrategias:

  • Cache — retornar la última respuesta válida. Funciona bien para datos que cambian poco (catálogo de productos, configuraciones).
  • Valor por defecto — retornar un valor seguro predefinido. Funciona para cálculos de envío ("envío a confirmar") o stock ("consulte disponibilidad").
  • Funcionalidad reducida — el checkout funciona sin la validación del marketplace, pero avisa al usuario que el precio será confirmado después.
  • Cola para retry posterior — guardar la operación para intentar de nuevo cuando el servicio vuelva. Funciona para operaciones que no necesitan respuesta inmediata.
// Fallback con cache
const productCache = new Map<string, Product>()

async function getProduct(id: string): Promise<Product> {
  return marketplaceBreaker.execute(
    async () => {
      const product = await fetchFromMarketplace(id)
      productCache.set(id, product) // actualiza cache en caso de éxito
      return product
    },
    () => {
      const cached = productCache.get(id)
      if (cached) return cached
      // Sin cache — retorna producto con flag de indisponibilidad
      return { id, name: 'Producto no disponible', available: false }
    }
  )
}

Combinando con retry

Circuit Breaker y retry resuelven problemas diferentes:

  • Retry — para fallas puntuales (un request falló, el próximo probablemente funciona). Usa backoff exponencial.
  • Circuit Breaker — para fallas sostenidas (el servicio está caído, no tiene sentido seguir intentando). Protege todo el sistema.

La combinación correcta: retry dentro del circuit breaker. El circuit monitorea si los retries están funcionando. Si incluso con retry la llamada sigue fallando, el circuit se abre.

async function fetchWithRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  let lastError: Error | null = null

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn()
    } catch (error) {
      lastError = error as Error
      // Backoff exponencial: 1s, 2s, 4s
      await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000))
    }
  }

  throw lastError
}

// Retry dentro del circuit breaker
async function getProductReliably(id: string) {
  return marketplaceBreaker.execute(
    () => fetchWithRetry(() => fetchFromMarketplace(id), 3),
    () => getCachedProduct(id)
  )
}

Monitoreo: saber cuándo el circuit se abre

Un circuit breaker que se abre silenciosamente es peligroso — necesitas saber cuándo un servicio externo está inestable. Agregar eventos de transición de estado resuelve esto:

class ObservableCircuitBreaker extends CircuitBreaker {
  private name: string

  constructor(name: string, options?: Partial<CircuitBreakerOptions>) {
    super(options)
    this.name = name
  }

  async execute<T>(fn: () => Promise<T>, fallback: () => T): Promise<T> {
    const previousState = this.getState()
    const result = await super.execute(fn, fallback)
    const currentState = this.getState()

    if (previousState !== currentState) {
      this.logTransition(previousState, currentState)
    }

    return result
  }

  private logTransition(from: string, to: string) {
    const message = `[CircuitBreaker:${this.name}] ${from} → ${to}`

    if (to === 'OPEN') {
      console.error(message) // alertar cuando abre
      // Enviar a monitoring (Datadog, Sentry, etc.)
    } else if (to === 'CLOSED') {
      console.info(message) // informar cuando recupera
    } else {
      console.warn(message) // half-open es transición
    }
  }
}

Con esto, sabes exactamente cuándo un servicio externo empieza a fallar y cuándo se recupera. En producción, estos logs deben ir a un sistema de alertas — si el circuit del proveedor de pago se abre, alguien necesita ser notificado.

Escenario real: API de marketplace inconsistente

Un escenario que enfrenté: una API de marketplace retornaba datos de productos de forma inconsistente. A veces funcionaba en 200ms, a veces demoraba 15 segundos, a veces retornaba 500. El checkout dependía de esta API para validar precio y stock.

La solución fue:

  1. Circuit Breaker con threshold de 3 fallas y cooldown de 30s
  2. Timeout agresivo de 3s (si no respondió en 3s, considera falla)
  3. Cache como fallback — última respuesta válida del producto, con flag de "precio sujeto a confirmación"
  4. Reconciliación posterior — job que corre cada 5 minutos verificando si los precios del cache siguen correctos

El resultado: el checkout nunca más se colgó por causa del marketplace. Cuando la API estaba inestable, los usuarios veían los precios del cache con un aviso sutil. Cuando volvía a la normalidad, el circuit se cerraba y todo volvía a funcionar en tiempo real.

Cuándo no usar Circuit Breaker

No toda llamada externa necesita un circuit breaker:

  • Llamadas que ya son resilientes — si la dependencia tiene retry nativo y SLA alto (ej: AWS S3), el overhead del circuit puede ser innecesario.
  • Operaciones idempotentes con cola — si ya usas una cola con retry (ej: procesamiento de webhooks), la cola ya cumple el rol del circuit.
  • Llamadas no críticas — analytics, logging externo, tracking. Si falla, no afecta al usuario.

Resumen

El Circuit Breaker es una de las herramientas más importantes para aplicaciones que dependen de servicios externos. El patrón en sí es simple — una state machine con tres estados. Lo que requiere ingeniería es:

  1. Calibrar los thresholds — cuántas fallas antes de abrir, cuánto tiempo de cooldown
  2. Diseñar fallbacks útiles — cache, valor por defecto, funcionalidad reducida
  3. Monitorear transiciones — saber cuándo se abrió y cuándo se cerró
  4. Un breaker por dependencia — aislar fallas para que una API inestable no afecte a las otras
  5. Combinar con retry — retry para fallas puntuales, circuit para fallas sostenidas

La diferencia entre un sistema que cae junto con sus dependencias y uno que degrada graciosamente es casi siempre un Circuit Breaker bien configurado.