El circuit breaker es uno de los patrones de resiliencia más conocidos en sistemas distribuidos. Envuelve las llamadas a una dependencia y, tras superar un umbral configurable de fallos, se “abre” — fallando rápido en lugar de dejar que las peticiones se acumulen esperando a un servicio que ya está caído.
La mayoría de ingenieros entienden el concepto. Muchos menos lo han instrumentado correctamente. Y esa brecha aparece en el peor momento: durante un incidente, cuando necesitas saber si tu circuit breaker es la causa de la degradación o tu última línea de defensa contra ella.
Cómo funciona el circuit breaker
El patrón tiene tres estados:
- CLOSED: Operación normal. Las peticiones fluyen. Los fallos se cuentan.
- OPEN: Umbral de fallos superado. Las peticiones fallan inmediatamente sin llamar a la dependencia.
- HALF-OPEN: Estado de sonda. Se permite pasar un número limitado de peticiones para comprobar si la dependencia se recuperó.
La máquina de estados dirige el comportamiento. Lo que la mayoría de equipos olvidan instrumentar son las transiciones — no solo el estado actual.
Implementación mínima en Kotlin
import java.time.Instant
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
enum class CircuitState { CLOSED, OPEN, HALF_OPEN }
data class CircuitBreakerConfig(
val failureThreshold: Int = 5,
val successThreshold: Int = 2,
val openDurationMs: Long = 10_000,
)
class CircuitBreaker(
val name: String,
private val config: CircuitBreakerConfig = CircuitBreakerConfig(),
) {
private val state = AtomicReference(CircuitState.CLOSED)
private val failureCount = AtomicInteger(0)
private val successCount = AtomicInteger(0)
private var openedAt: Instant? = null
fun <T> execute(block: () -> T): T {
return when (currentState()) {
CircuitState.OPEN -> throw CircuitOpenException("Circuit '$name' está ABIERTO")
else -> {
try {
val result = block()
onSuccess()
result
} catch (e: Exception) {
onFailure()
throw e
}
}
}
}
private fun currentState(): CircuitState {
val s = state.get()
if (s == CircuitState.OPEN) {
val elapsed = Instant.now().toEpochMilli() - (openedAt?.toEpochMilli() ?: 0)
if (elapsed >= config.openDurationMs) {
transitionTo(CircuitState.HALF_OPEN)
return CircuitState.HALF_OPEN
}
}
return s
}
private fun onSuccess() {
if (state.get() == CircuitState.HALF_OPEN) {
if (successCount.incrementAndGet() >= config.successThreshold) {
successCount.set(0); failureCount.set(0)
transitionTo(CircuitState.CLOSED)
}
} else {
failureCount.set(0)
}
}
private fun onFailure() {
if (state.get() == CircuitState.HALF_OPEN) {
transitionTo(CircuitState.OPEN)
} else if (failureCount.incrementAndGet() >= config.failureThreshold) {
transitionTo(CircuitState.OPEN)
}
}
private fun transitionTo(next: CircuitState) {
val previous = state.getAndSet(next)
if (previous != next && next == CircuitState.OPEN) openedAt = Instant.now()
}
}
class CircuitOpenException(message: String) : RuntimeException(message)
Esto funciona, pero es completamente opaco. Cuando el circuito se abre en producción verás errores — pero no por qué se abrió, cuánto tiempo lleva abierto, ni cuántas peticiones ha cortocircuitado.
Añadiendo métricas
Las métricas son la base. Necesitas saber en cualquier momento:
- En qué estado está cada circuit breaker
- Cuántas transiciones de estado han ocurrido
- Cuántas llamadas tuvieron éxito, fallaron, o fueron rechazadas (fast-failed)
import io.opentelemetry.api.GlobalOpenTelemetry
import io.opentelemetry.api.common.Attributes
import io.opentelemetry.api.metrics.LongCounter
import io.opentelemetry.api.metrics.ObservableLongGauge
class ObservableCircuitBreaker(
val name: String,
private val config: CircuitBreakerConfig = CircuitBreakerConfig(),
) {
private val state = AtomicReference(CircuitState.CLOSED)
private val failureCount = AtomicInteger(0)
private val successCount = AtomicInteger(0)
private var openedAt: Instant? = null
private val meter = GlobalOpenTelemetry.getMeter("circuit-breaker")
private val callCounter: LongCounter = meter
.counterBuilder("circuit_breaker.calls.total")
.setDescription("Total de llamadas a través del circuit breaker, por resultado")
.build()
private val transitionCounter: LongCounter = meter
.counterBuilder("circuit_breaker.transitions.total")
.setDescription("Número de transiciones de estado")
.build()
// Gauge: 0=CLOSED, 1=OPEN, 2=HALF_OPEN
private val stateGauge: ObservableLongGauge = meter
.gaugeBuilder("circuit_breaker.state")
.setDescription("Estado actual del circuit breaker")
.ofLongs()
.buildWithCallback { measurement ->
val v = when (state.get()) {
CircuitState.CLOSED -> 0L
CircuitState.OPEN -> 1L
CircuitState.HALF_OPEN -> 2L
}
measurement.record(v, Attributes.builder()
.put("circuit_breaker.name", name).build())
}
fun <T> execute(block: () -> T): T {
val current = currentState()
return if (current == CircuitState.OPEN) {
callCounter.add(1, attrsWithOutcome("rejected"))
throw CircuitOpenException("Circuit '$name' está ABIERTO")
} else {
try {
val result = block()
callCounter.add(1, attrsWithOutcome("success"))
onSuccess()
result
} catch (e: CircuitOpenException) { throw e }
catch (e: Exception) {
callCounter.add(1, attrsWithOutcome("failure"))
onFailure()
throw e
}
}
}
private fun transitionTo(next: CircuitState) {
val previous = state.getAndSet(next)
if (previous != next) {
if (next == CircuitState.OPEN) openedAt = Instant.now()
transitionCounter.add(1, Attributes.builder()
.put("circuit_breaker.name", name)
.put("circuit_breaker.previous_state", previous.name)
.put("circuit_breaker.new_state", next.name)
.build())
}
}
private fun attrsWithOutcome(outcome: String) = Attributes.builder()
.put("circuit_breaker.name", name)
.put("outcome", outcome)
.build()
// currentState / onSuccess / onFailure igual que antes
}
Con estas tres métricas puedes construir un dashboard que muestre: estado por circuito, tasa de rechazo, y tasa de transiciones. La alerta más importante: dispara si circuit_breaker.state == 1 (OPEN) durante más tiempo que openDurationMs más un margen — significa que la dependencia nunca se recuperó.
Añadiendo trazas
Las métricas te dicen qué ocurrió. Las trazas te dicen qué peticiones se vieron afectadas.
import io.opentelemetry.api.GlobalOpenTelemetry
import io.opentelemetry.api.trace.SpanKind
import io.opentelemetry.api.trace.StatusCode
private val tracer = GlobalOpenTelemetry.getTracer("circuit-breaker")
fun <T> execute(block: () -> T): T {
val currentState = currentState()
val span = tracer.spanBuilder("circuit_breaker.execute")
.setSpanKind(SpanKind.INTERNAL)
.setAttribute("circuit_breaker.name", name)
.setAttribute("circuit_breaker.state", currentState.name)
.startSpan()
return span.makeCurrent().use {
try {
if (currentState == CircuitState.OPEN) {
callCounter.add(1, attrsWithOutcome("rejected"))
span.setAttribute("circuit_breaker.rejected", true)
span.setStatus(StatusCode.ERROR, "Circuit está OPEN")
throw CircuitOpenException("Circuit '$name' está ABIERTO")
}
val result = block()
callCounter.add(1, attrsWithOutcome("success"))
span.setStatus(StatusCode.OK)
onSuccess()
result
} catch (e: CircuitOpenException) { throw e }
catch (e: Exception) {
callCounter.add(1, attrsWithOutcome("failure"))
span.recordException(e)
span.setStatus(StatusCode.ERROR)
onFailure()
throw e
} finally {
span.end()
}
}
}
El atributo circuit_breaker.state en cada span es especialmente potente. En Jaeger o Tempo, filtra por circuit_breaker.state = "OPEN" para ver el radio de explosión exacto de una caída: qué servicios estaban llamando al circuito, a qué ritmo, y durante cuánto tiempo.
Logueando transiciones de estado
Los logs te dan la narrativa — el momento exacto en que el circuito saltó, con contexto de negocio.
import org.slf4j.LoggerFactory
private val log = LoggerFactory.getLogger(ObservableCircuitBreaker::class.java)
private fun transitionTo(next: CircuitState) {
val previous = state.getAndSet(next)
if (previous != next) {
if (next == CircuitState.OPEN) openedAt = Instant.now()
when (next) {
CircuitState.OPEN -> log.warn(
"Circuit breaker abierto. name={} failures={} threshold={}",
name, failureCount.get(), config.failureThreshold
)
CircuitState.HALF_OPEN -> log.info(
"Circuit breaker sondeando recuperación. name={} open_duration_ms={}",
name, Instant.now().toEpochMilli() - (openedAt?.toEpochMilli() ?: 0)
)
CircuitState.CLOSED -> log.info(
"Circuit breaker cerrado — dependencia recuperada. name={}",
name
)
}
// emitir métrica...
}
}
Usa logging estructurado en lugar de interpolación de strings para poder consultar en tu agregador de logs todos los eventos de un circuito específico sin parsear cadenas.
Usando Resilience4j y ampliando su observabilidad
La mayoría de equipos usan Resilience4j en lugar de una implementación propia. Su integración con Micrometer cubre lo básico, pero el logging de transiciones de estado es escaso. Añade tu propio listener:
import io.github.resilience4j.circuitbreaker.CircuitBreaker
import io.github.resilience4j.circuitbreaker.CircuitBreaker.State
fun CircuitBreaker.registerOtelObservability() {
val meter = GlobalOpenTelemetry.getMeter("resilience4j.circuit-breaker")
val log = LoggerFactory.getLogger("circuit-breaker.${this.name}")
meter.gaugeBuilder("resilience4j_circuit_breaker_state")
.ofLongs()
.buildWithCallback { m ->
val v = when (this.state) {
State.CLOSED -> 0L; State.OPEN -> 1L
State.HALF_OPEN -> 2L; else -> -1L
}
m.record(v, Attributes.builder().put("name", this.name).build())
}
this.eventPublisher.onStateTransition { event ->
log.warn(
"Transición de circuit breaker. name={} from={} to={} failure_rate={} buffered={}",
this.name,
event.stateTransition.fromState,
event.stateTransition.toState,
this.metrics.failureRate,
this.metrics.numberOfBufferedCalls,
)
}
}
Llama a circuitBreaker.registerOtelObservability() una vez tras crear cada breaker.
Cómo son los buenos dashboards
Con esta instrumentación, un dashboard útil tiene:
Fila 1 — Salud actual
- Estado por circuito (panel stat, con código de color: verde/rojo/amarillo)
- Tasa de rechazo en los últimos 5 minutos
- Tiempo desde la última transición
Fila 2 — Tendencias
circuit_breaker.calls.totaldividido poroutcome(success, failure, rejected) a lo largo del tiempocircuit_breaker.transitions.totaldividido por tipo de transición — un circuito que alterna entre OPEN y HALF-OPEN cada 30 segundos señala que la dependencia no se está recuperando realmente
Fila 3 — Correlación
- Superpón las transiciones de estado del circuit breaker sobre los gráficos de tasa de error y latencia p99 de tu servicio. Esto responde inmediatamente: “¿nuestra tasa de error subió porque el circuito se abrió, o el circuito se abrió por el pico?”
Las preguntas que deberías poder responder
Tras añadir esta observabilidad, deberías poder responder todo lo siguiente desde dashboards y trazas:
- ¿Qué circuit breakers están actualmente OPEN?
- ¿Cuánto tiempo lleva abierto un circuito concreto?
- ¿Qué tasa de fallos provocó la transición?
- ¿Cuántas peticiones fueron fast-failed mientras el circuito estaba abierto?
- ¿Se recuperó el circuito, o sigue oscilando?
- ¿Qué servicios upstream estaban llamando a un circuito que se abrió?
Si hoy no puedes responder alguna de estas, empieza por las métricas — son la ganancia más rápida. Luego añade eventos de log estructurados para las transiciones, y finalmente atributos en los spans para correlacionar el estado del circuit breaker con trazas de peticiones individuales.
El circuit breaker protege tu sistema. La observabilidad protege tu capacidad de entenderlo.