Cada circuit breaker de producción que he revisado eventualmente lanza un hilo en segundo plano. Puede ser una goroutine de Go, un ScheduledExecutorService de Java, o una tarea tokio de Rust. El trabajo siempre es el mismo: despertar cada pocos segundos, verificar si el servicio downstream se ha recuperado, y transicionar de OPEN de vuelta a CLOSED.

Ese diseño está mal. Fuga recursos a escala, complica el shutdown, y crea condiciones de carrera que son genuinamente difíciles de probar. Peor aún, el trabajo en segundo plano es completamente innecesario. Puedes construir un circuit breaker que nunca se despierta por su cuenta, nunca asigna un timer, y aún así detecta correctamente la recuperación.

El costo oculto de las goroutines de health-check

Un circuit breaker rastrea fallos. Después de suficientes errores consecutivos, dispara a OPEN y empieza a rechazar requests inmediatamente. El objetivo es darle un respiro al servicio que falla en lugar de ahogarlo con tráfico de reintentos.

La parte complicada es decidir cuándo intentar de nuevo. La mayoría de las bibliotecas resuelven esto con un setTimeout o time.AfterFunc. En Go, una implementación típica se ve así:

func (cb *CircuitBreaker) Trip() {
    cb.state.Store(StateOpen)
    time.AfterFunc(cb.timeout, func() {
        cb.state.Store(StateHalfOpen)
    })
}

Esto funciona para un solo breaker. No funciona para diez mil.

Si creas un circuit breaker por host downstream (un patrón común en microservicios), ahora tienes diez mil goroutines durmiendo en segundo plano. Cada goroutine cuesta ~2 KB de stack space y añade overhead de scheduling. En reinicios de containers, esas goroutines compiten contra deadlines de shutdown. En timeouts, se disparan exactamente en el momento equivocado y crean flapping.

El hilo en segundo plano está resolviendo un problema que no existe. La recuperación no necesita ser detectada proactivamente. Puede ser detectada de forma lazy, en el request path.

Cómo funciona la recuperación lazy

En lugar de un timer que transiciona el breaker, almacena un único timestamp: el momento en que el breaker disparó a OPEN. En cada request entrante, compara now contra ese timestamp más el timeout configurado. Si ha transcurrido suficiente tiempo, deja pasar un único probe. Si el probe tiene éxito, cierra el breaker. Si falla, actualiza el timestamp y permanece en OPEN.

La state machine se mantiene idéntica. Solo cambia el trigger de transición.

  • CLOSED: los requests pasan. Los fallos incrementan un contador. Cuando el contador alcanza el threshold, intercambia atómicamente a OPEN y registra trippedAt.
  • OPEN: cada request entrante verifica time.Now() > trippedAt + timeout. Si es falso, fail fast. Si es verdadero, intercambia atómicamente a HALF-OPEN y deja pasar este request.
  • HALF-OPEN: exactamente un request está en vuelo. Si tiene éxito, intercambia a CLOSED y reinicia el contador de fallos. Si falla, vuelve a OPEN y actualiza trippedAt.

Ninguna goroutine se despierta nunca. No se asigna ningún timer. El breaker es completamente pasivo hasta que llega un request.

Una implementación funcional en Go

Aquí tienes un circuit breaker completo y sin overhead en segundo plano. Usa únicamente sync/atomic para las transiciones de estado y almacena el timestamp de disparo como un contador de nanosegundos.

package breaker

import (
	"errors"
	"sync/atomic"
	"time"
)

type State int32

const (
	StateClosed State = iota
	StateOpen
	StateHalfOpen
)

type CircuitBreaker struct {
	// state is accessed with atomic operations.
	state      int32
	failures   int32
	threshold  int32
	timeout    time.Duration
	trippedAt  int64 // nanoseconds since Unix epoch
}

func New(threshold int, timeout time.Duration) *CircuitBreaker {
	return &CircuitBreaker{
		threshold: int32(threshold),
		timeout:   timeout,
	}
}

func (cb *CircuitBreaker) State() State {
	return State(atomic.LoadInt32(&cb.state))
}

// Allow reports whether the current request may proceed.
// It returns a done function that must be called with the outcome.
func (cb *CircuitBreaker) Allow() (done func(success bool), err error) {
	switch State(atomic.LoadInt32(&cb.state)) {
	case StateClosed:
		return cb.trackClosed, nil

	case StateOpen:
		// Lazy recovery check: has the timeout elapsed?
		if time.Now().UnixNano()-atomic.LoadInt64(&cb.trippedAt) < int64(cb.timeout) {
			return nil, errors.New("circuit breaker is open")
		}
		// Race: multiple goroutines may see this simultaneously.
		// Only one wins the CAS to HALF-OPEN.
		if atomic.CompareAndSwapInt32(&cb.state, int32(StateOpen), int32(StateHalfOpen)) {
			return cb.trackHalfOpen, nil
		}
		// Another goroutine won the race; fail fast this request.
		return nil, errors.New("circuit breaker is open")

	case StateHalfOpen:
		// Only one probe at a time. Every other request fails fast.
		return nil, errors.New("circuit breaker is half-open")
	}

	return nil, errors.New("unknown circuit breaker state")
}

func (cb *CircuitBreaker) trackClosed(success bool) {
	if success {
		atomic.StoreInt32(&cb.failures, 0)
		return
	}

	// Increment failures and trip if threshold reached.
	if atomic.AddInt32(&cb.failures, 1) >= cb.threshold {
		// Record the trip time before switching state so readers
		// never see OPEN with a stale trippedAt.
		atomic.StoreInt64(&cb.trippedAt, time.Now().UnixNano())
		atomic.StoreInt32(&cb.state, int32(StateOpen))
	}
}

func (cb *CircuitBreaker) trackHalfOpen(success bool) {
	if success {
		atomic.StoreInt32(&cb.failures, 0)
		atomic.StoreInt32(&cb.state, int32(StateClosed))
		return
	}

	atomic.StoreInt64(&cb.trippedAt, time.Now().UnixNano())
	atomic.StoreInt32(&cb.state, int32(StateOpen))
}

La invariante clave: trippedAt siempre se escribe antes de que el estado transicione a OPEN. Los lectores en Allow() pueden entonces leer trippedAt de forma segura después de ver OPEN, sabiendo que es fresco. En el return path de HALF-OPEN, actualizamos trippedAt antes de caer de vuelta a OPEN para que el cooldown se reinicie desde cero.

Por qué la mayoría de las bibliotecas no hacen esto

El diseño lazy tiene una desventaja aparente: la recuperación solo se detecta cuando llega un request. Si tu servicio no recibe tráfico durante una hora, el breaker permanece OPEN durante una hora.

Esto suena mal. No lo es.

Si no hay requests, no hay nada que proteger. El breaker existe para prevenir fallos en cascada durante el tráfico, no para mantener un dashboard de salud en tiempo real. Cuando el siguiente request llega, la verificación del timeout se ejecuta en nanosegundos y el probe se dispara inmediatamente. La latencia efectiva de recuperación está acotada por max(timeout, time-between-requests).

Para servicios de alto tráfico, el gap entre requests es insignificante. Para servicios de bajo tráfico, el timeout domina de todos modos. El timer en segundo plano casi nunca mejora el tiempo real de recuperación en la práctica.

La otra razón por la que las bibliotecas usan timers es histórica. El patrón de circuit breaker se popularizó en entornos (Java con Hystrix, .NET con Polly) donde una única instancia de breaker protegía toda una dependencia de servicio, no una conexión por host. Un hilo en segundo plano era aceptable. En sistemas distribuidos modernos, donde puedes tener un breaker por endpoint upstream, esa suposición se rompe.

Probando las condiciones de carrera

El bucle CAS en la transición de OPEN a HALF-OPEN es el único lugar donde las goroutines compiten. Si dos requests llegan simultáneamente después del timeout, solo uno procede como probe. El otro hace fail fast y reintenta en el siguiente request. Este es el comportamiento correcto. Nunca quieres múltiples probes en vuelo durante la recuperación, porque un único fallo entre varios éxitos podría aún así devolverte a OPEN.

Las pruebas son sencillas porque no hay timers asíncronos. Puedes escribir una unit test que manipule trippedAt directamente (o use un wrapper de tiempo) sin dormir:

func TestLazyRecovery(t *testing.T) {
	cb := New(1, time.Minute)

	// Trip the breaker.
	done, _ := cb.Allow()
	done(false)

	if cb.State() != StateOpen {
		t.Fatal("expected OPEN")
	}

	// Simulate timeout by winding back trippedAt.
	atomic.StoreInt64(&cb.trippedAt, time.Now().Add(-2*time.Minute).UnixNano())

	done, err := cb.Allow()
	if err != nil {
		t.Fatalf("expected probe to be allowed: %v", err)
	}

	// Success closes the breaker.
	done(true)
	if cb.State() != StateClosed {
		t.Fatal("expected CLOSED after successful probe")
	}
}

Sin time.Sleep. Sin sync.WaitGroup para goroutines. La prueba es determinista porque la implementación es síncrona.

Lo que renunciamos

Hay una pérdida real: no puedes pre-calentar eagermente un breaker antes de enviar tráfico. Si necesitas sondear una dependencia en un horario fijo (digamos, cada 5 segundos) para mantener un connection pool caliente, aún necesitas un timer. Pero ese timer pertenece a tu connection pool o health checker, no al circuit breaker. El breaker debería proteger el pool. No debería gestionarlo.

Mantén las preocupaciones separadas. El health checking calienta conexiones. El circuit breaking previene la sobrecarga en cascada. Cuando los fusionas, obtienes complejidad en ambos lados.

Un patrón para otros lenguajes

La misma estructura funciona en cualquier lugar donde tengas atomic compare-and-swap y un reloj monotónico. En Rust con std::sync::atomic, en Java con AtomicIntegerFieldUpdater y System.nanoTime(), en C++ con std::atomic y un enum personalizado. La implementación está por debajo de cien líneas en cada caso.

Si tu lenguaje no expone CAS, un sync.Mutex (o equivalente) sigue siendo más barato que un hilo en segundo plano. El mutex solo se mantiene durante nanosegundos por request, y solo durante las transiciones de estado. Nunca bloquea por I/O ni duerme.

Pruébalo

La implementación completa de arriba está lista para producción como punto de partida. Añade metrics, logging, y thresholds adaptivos encima. Pero deja fuera la goroutine. Tu runtime scheduler te lo agradecerá, tus pruebas correrán más rápido, y dejarás de preguntarte por qué ese container se niega a hacer shutdown limpiamente.