SolveConPython

Python Reto #28 — Circuit Breaker simple

Nivel: Avanzado (práctico)
Tema: Resiliencia, patrones de tolerancia a fallos, estado, ventanas de tiempo, tests deterministas
Objetivo: Implementar un Circuit Breaker para evitar saturar servicios que están fallando: si hay demasiados errores, “abre el circuito” y rechaza llamadas temporalmente.

Concepto (rápido)

Un Circuit Breaker tiene tres estados:

  1. CLOSED (cerrado): las llamadas pasan normalmente. Si hay muchos fallos, pasa a OPEN.
  2. OPEN (abierto): las llamadas se bloquean inmediatamente durante un “cooldown”.
  3. HALF_OPEN (semiabierto): tras el cooldown, permite una llamada de prueba:
    • si funciona → vuelve a CLOSED y resetea fallos
    • si falla → vuelve a OPEN

Enunciado

Crea una clase CircuitBreaker con:

Constructor

CircuitBreaker(fallo_umbral=3, cooldown_segundos=5, ahora_fn=None)

Reglas:

  1. fallo_umbral debe ser int >= 1 (si no: TypeError/ValueError)
  2. cooldown_segundos debe ser int o float >= 0 (si no: TypeError/ValueError)
  3. ahora_fn (opcional) debe ser callable; si no se pasa, usa time.time

Método principal

call(func, excepciones=(Exception,))

Debe:

  1. Validar func callable.
  2. Validar excepciones como tupla de clases de excepción.
  3. Comportamiento por estado:
    • OPEN: si no ha pasado el cooldown → lanzar CircuitOpenError (una excepción propia)
    • OPEN: si ya pasó el cooldown → pasar a HALF_OPEN y permitir intento
    • HALF_OPEN: permitir solo un intento:
      • si éxito → cambiar a CLOSED y resetear fallos
      • si falla (excepción capturable) → volver a OPEN y reiniciar cooldown
    • CLOSED: ejecutar func():
      • si éxito → devolver resultado
      • si falla con excepción capturable → incrementar contador; si llega al umbral → OPEN
  4. Excepciones no incluidas en excepciones deben propagarse sin afectar el estado.

Extras

  • Método state() que devuelva "CLOSED", "OPEN", o "HALF_OPEN" para debug (opcional pero recomendable).

Ejemplos

Python
cb = CircuitBreaker(fallo_umbral=2, cooldown_segundos=10)
cb.call(mi_func) # si falla -> contador=1
cb.call(mi_func) # si falla -> contador=2 => OPEN
cb.call(mi_func) # CircuitOpenError (hasta que pase el cooldown)

Pistas

  1. Guarda:
    • self._estado (string)
    • self._fallos (int)
    • self._open_hasta (timestamp: cuándo se permite probar otra vez)
  2. Usa ahora_fn para tests (reloj fake).
  3. Para HALF_OPEN, basta con permitir una llamada y decidir estado tras esa llamada.

Python
import time
class CircuitOpenError(RuntimeError):
"""Se lanza cuando el circuito está OPEN y aún no finalizó el cooldown."""
class CircuitBreaker:
def __init__(self, fallo_umbral: int = 3, cooldown_segundos: float = 5, ahora_fn=None):
if fallo_umbral is None or not isinstance(fallo_umbral, int):
raise TypeError("El parámetro 'fallo_umbral' debe ser un entero (int).")
if fallo_umbral < 1:
raise ValueError("'fallo_umbral' debe ser >= 1.")
if cooldown_segundos is None or not isinstance(cooldown_segundos, (int, float)):
raise TypeError("El parámetro 'cooldown_segundos' debe ser numérico (int o float).")
if cooldown_segundos < 0:
raise ValueError("'cooldown_segundos' debe ser >= 0.")
if ahora_fn is not None and not callable(ahora_fn):
raise TypeError("El parámetro 'ahora_fn' debe ser callable si se proporciona.")
self.fallo_umbral = fallo_umbral
self.cooldown_segundos = float(cooldown_segundos)
self.ahora_fn = ahora_fn or time.time
self._estado = "CLOSED"
self._fallos = 0
self._open_hasta = 0.0 # timestamp
def state(self) -> str:
return self._estado
def _validar_excepciones(self, excepciones):
if not isinstance(excepciones, tuple) or not excepciones:
raise TypeError("El parámetro 'excepciones' debe ser una tupla no vacía de excepciones.")
for exc in excepciones:
if not isinstance(exc, type) or not issubclass(exc, BaseException):
raise TypeError("'excepciones' debe contener solo clases de excepción.")
def _abrir(self):
self._estado = "OPEN"
self._open_hasta = float(self.ahora_fn()) + self.cooldown_segundos
def _cerrar(self):
self._estado = "CLOSED"
self._fallos = 0
self._open_hasta = 0.0
def call(self, func, excepciones=(Exception,)):
if not callable(func):
raise TypeError("El parámetro 'func' debe ser callable.")
self._validar_excepciones(excepciones)
ahora = float(self.ahora_fn())
# Estado OPEN: bloquear hasta cooldown
if self._estado == "OPEN":
if ahora < self._open_hasta:
raise CircuitOpenError("Circuito OPEN: cooldown activo.")
# cooldown terminado: permitir un intento en HALF_OPEN
self._estado = "HALF_OPEN"
# Estado HALF_OPEN: una llamada de prueba
if self._estado == "HALF_OPEN":
try:
resultado = func()
except excepciones:
# falla: volver a OPEN y reiniciar cooldown
self._abrir()
raise
else:
# éxito: cerrar y resetear
self._cerrar()
return resultado
# Estado CLOSED: comportamiento normal
try:
return func()
except excepciones:
self._fallos += 1
if self._fallos >= self.fallo_umbral:
self._abrir()
raise

Python
import pytest
from reto_28_circuit_breaker import CircuitBreaker, CircuitOpenError
class RelojFake:
def __init__(self, t0=0.0):
self.t = float(t0)
def ahora(self):
return self.t
def avanzar(self, s):
self.t += float(s)
def test_pasa_a_open_al_alcanzar_umbral():
reloj = RelojFake(0.0)
cb = CircuitBreaker(fallo_umbral=2, cooldown_segundos=10, ahora_fn=reloj.ahora)
def falla():
raise ValueError("boom")
with pytest.raises(ValueError):
cb.call(falla, excepciones=(ValueError,))
assert cb.state() == "CLOSED"
with pytest.raises(ValueError):
cb.call(falla, excepciones=(ValueError,))
assert cb.state() == "OPEN"
def test_open_bloquea_hasta_cooldown():
reloj = RelojFake(0.0)
cb = CircuitBreaker(fallo_umbral=1, cooldown_segundos=5, ahora_fn=reloj.ahora)
def falla():
raise ValueError("boom")
with pytest.raises(ValueError):
cb.call(falla, excepciones=(ValueError,))
assert cb.state() == "OPEN"
with pytest.raises(CircuitOpenError):
cb.call(lambda: "ok", excepciones=(ValueError,))
assert cb.state() == "OPEN"
def test_half_open_exito_cierra():
reloj = RelojFake(0.0)
cb = CircuitBreaker(fallo_umbral=1, cooldown_segundos=5, ahora_fn=reloj.ahora)
def falla():
raise ValueError("boom")
with pytest.raises(ValueError):
cb.call(falla, excepciones=(ValueError,))
assert cb.state() == "OPEN"
reloj.avanzar(5.0) # termina cooldown
# primer intento en HALF_OPEN: éxito -> CLOSED
assert cb.call(lambda: "ok", excepciones=(ValueError,)) == "ok"
assert cb.state() == "CLOSED"
def test_half_open_fallo_reabre():
reloj = RelojFake(0.0)
cb = CircuitBreaker(fallo_umbral=1, cooldown_segundos=5, ahora_fn=reloj.ahora)
with pytest.raises(ValueError):
cb.call(lambda: (_ for _ in ()).throw(ValueError("boom")), excepciones=(ValueError,))
assert cb.state() == "OPEN"
reloj.avanzar(5.0) # termina cooldown
# intento en HALF_OPEN falla -> vuelve a OPEN
with pytest.raises(ValueError):
cb.call(lambda: (_ for _ in ()).throw(ValueError("boom2")), excepciones=(ValueError,))
assert cb.state() == "OPEN"
def test_excepcion_no_capturada_no_afecta_estado():
reloj = RelojFake(0.0)
cb = CircuitBreaker(fallo_umbral=1, cooldown_segundos=5, ahora_fn=reloj.ahora)
def lanza_keyerror():
raise KeyError("no capturada")
with pytest.raises(KeyError):
cb.call(lanza_keyerror, excepciones=(ValueError,))
assert cb.state() == "CLOSED"

Ejecuta:

  • pytest -q

Variantes para subir de nivel (opcional)

  1. Ventana deslizante: umbral basado en fallos en los últimos N segundos
  2. Registrar métricas: conteo de opens, half-opens, resets, etc.
  3. Callbacks: on_open, on_close, on_half_open
  4. Soporte para func(*args, **kwargs)
  5. Integración con el Reto #27 (retry + circuit breaker)

Accede al código completo y a los tests en GitHub para ejecutar y modificar la solución localmente.

El siguiente reto recomendado es: Reto #29 — Cola de tareas (queue) con workers y reintentos para unir ETL, resiliencia y estado (muy “real world”).