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:
- CLOSED (cerrado): las llamadas pasan normalmente. Si hay muchos fallos, pasa a OPEN.
- OPEN (abierto): las llamadas se bloquean inmediatamente durante un “cooldown”.
- 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:
fallo_umbraldebe serint>= 1 (si no:TypeError/ValueError)cooldown_segundosdebe serintofloat>= 0 (si no:TypeError/ValueError)ahora_fn(opcional) debe ser callable; si no se pasa, usatime.time
Método principal
call(func, excepciones=(Exception,))
Debe:
- Validar
funccallable. - Validar
excepcionescomo tupla de clases de excepción. - 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
- OPEN: si no ha pasado el cooldown → lanzar
- Excepciones no incluidas en
excepcionesdeben 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=1cb.call(mi_func) # si falla -> contador=2 => OPENcb.call(mi_func) # CircuitOpenError (hasta que pase el cooldown)
Pistas
- Guarda:
self._estado(string)self._fallos(int)self._open_hasta(timestamp: cuándo se permite probar otra vez)
- Usa
ahora_fnpara tests (reloj fake). - Para HALF_OPEN, basta con permitir una llamada y decidir estado tras esa llamada.
Python
import timeclass 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 pytestfrom reto_28_circuit_breaker import CircuitBreaker, CircuitOpenErrorclass 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)
- Ventana deslizante: umbral basado en fallos en los últimos N segundos
- Registrar métricas: conteo de opens, half-opens, resets, etc.
- Callbacks:
on_open,on_close,on_half_open - Soporte para
func(*args, **kwargs) - 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”).