Nivel: Intermedio+
Tema: Resiliencia, reintentos, backoff exponencial, jitter opcional, diseño de APIs, tests deterministas
Objetivo: Implementar una función de reintentos robusta para operaciones que pueden fallar temporalmente (red, IO, APIs), sin depender de librerías externas.
Enunciado
Crea una función llamada:
retry(func, intentos=3, delay_inicial=0.1, factor=2.0, excepciones=(Exception,), sleep_fn=None)
Que:
- Reciba:
func: callable sin argumentos (lo llamaremos comofunc())intentos: número máximo de intentos (int)delay_inicial: tiempo inicial de espera en segundos (float/int)factor: multiplicador de backoff (float/int)excepciones: tupla de clases de excepción a capturarsleep_fn: función para dormir (por defectotime.sleep) para poder testear sin esperar
- Validación:
funcdebe ser callable oTypeErrorintentosdebe serint>= 1 oTypeError/ValueErrordelay_inicialdebe ser numérico >= 0 oTypeError/ValueErrorfactordebe ser numérico > 0 oTypeError/ValueErrorexcepcionesdebe ser una tupla de excepciones oTypeErrorsleep_fnsi se pasa debe ser callable oTypeError
- Comportamiento:
- Llama a
func()hastaintentosveces. - Si
func()funciona, devuelve su resultado inmediatamente. - Si
func()lanza una excepción incluida enexcepciones:- si aún quedan intentos, espera
delaysegundos y reintenta, - multiplica el delay por
factorpara el siguiente intento.
- si aún quedan intentos, espera
- Si se agotan los intentos, debe relanzar la última excepción capturada.
- Si
func()lanza una excepción que no está enexcepciones, debe propagarse sin reintentos.
- Llama a
Ejemplos
Python
# reintenta 3 veces: delays 0.1s, 0.2s (y luego falla si sigue fallando)retry(mi_func, intentos=3, delay_inicial=0.1, factor=2.0)# capturar solo ValueErrorretry(mi_func, excepciones=(ValueError,))
Pistas
- Usa un bucle
forowhilecontando intentos. - Guarda el
delayactual en una variable y actualízalo multiplicando porfactor. - Inyectar
sleep_fnpermite tests sin pausas reales.
Solución explicada (paso a paso)
- Validar parámetros.
- Inicializar
delay = delay_inicial. - Repetir:
- intentar ejecutar
func() - si falla con excepción capturable:
- si es el último intento → relanzar
- si no → dormir
delayy actualizardelay *= factor
- intentar ejecutar
- Devolver resultado si éxito.
Python
import timedef retry( func, intentos: int = 3, delay_inicial: float = 0.1, factor: float = 2.0, excepciones: tuple = (Exception,), sleep_fn=None,): """ Ejecuta func() con reintentos y backoff exponencial. - Captura solo excepciones indicadas en 'excepciones' - Espera delay entre intentos, multiplicándolo por 'factor' - Si se agotan intentos, relanza la última excepción capturada """ if not callable(func): raise TypeError("El parámetro 'func' debe ser callable.") if intentos is None or not isinstance(intentos, int): raise TypeError("El parámetro 'intentos' debe ser un entero (int).") if intentos < 1: raise ValueError("'intentos' debe ser >= 1.") if delay_inicial is None or not isinstance(delay_inicial, (int, float)): raise TypeError("El parámetro 'delay_inicial' debe ser numérico (int o float).") if delay_inicial < 0: raise ValueError("'delay_inicial' debe ser >= 0.") if factor is None or not isinstance(factor, (int, float)): raise TypeError("El parámetro 'factor' debe ser numérico (int o float).") if factor <= 0: raise ValueError("'factor' debe ser > 0.") if not isinstance(excepciones, tuple) or not excepciones: raise TypeError("El parámetro 'excepciones' debe ser una tupla no vacía de excepciones.") # Validar que sean clases de excepción for exc in excepciones: if not isinstance(exc, type) or not issubclass(exc, BaseException): raise TypeError("'excepciones' debe contener solo clases de excepción.") if sleep_fn is None: sleep_fn = time.sleep elif not callable(sleep_fn): raise TypeError("El parámetro 'sleep_fn' debe ser callable si se proporciona.") delay = float(delay_inicial) ultimo_error = None for intento in range(1, intentos + 1): try: return func() except excepciones as e: ultimo_error = e if intento == intentos: raise # relanza la última excepción capturada if delay > 0: sleep_fn(delay) delay *= float(factor) # No debería llegar aquí, pero por seguridad: if ultimo_error: raise ultimo_error
Python
import pytestfrom reto_27_retry_backoff import retryclass SleepFake: def __init__(self): self.calls = [] def sleep(self, seconds): self.calls.append(seconds)def test_retry_exito_despues_de_fallos(): sleep = SleepFake() estado = {"n": 0} def f(): estado["n"] += 1 if estado["n"] < 3: raise ValueError("fallo temporal") return "ok" res = retry(f, intentos=5, delay_inicial=0.1, factor=2.0, excepciones=(ValueError,), sleep_fn=sleep.sleep) assert res == "ok" assert estado["n"] == 3 assert sleep.calls == [0.1, 0.2] # esperó entre intentos fallidosdef test_retry_relanzar_ultima_excepcion(): sleep = SleepFake() def f(): raise ValueError("siempre falla") with pytest.raises(ValueError): retry(f, intentos=3, delay_inicial=0.1, factor=2.0, excepciones=(ValueError,), sleep_fn=sleep.sleep) assert sleep.calls == [0.1, 0.2] # no duerme después del último intentodef test_excepcion_no_capturada_no_reintenta(): sleep = SleepFake() estado = {"n": 0} def f(): estado["n"] += 1 raise KeyError("no capturada") with pytest.raises(KeyError): retry(f, intentos=5, delay_inicial=0.1, factor=2.0, excepciones=(ValueError,), sleep_fn=sleep.sleep) assert estado["n"] == 1 assert sleep.calls == []def test_validaciones(): with pytest.raises(TypeError): retry(123) with pytest.raises(ValueError): retry(lambda: 1, intentos=0) with pytest.raises(ValueError): retry(lambda: 1, delay_inicial=-1) with pytest.raises(ValueError): retry(lambda: 1, factor=0) with pytest.raises(TypeError): retry(lambda: 1, excepciones="no_tuple") with pytest.raises(TypeError): retry(lambda: 1, sleep_fn="no_callable")
Ejecuta:
pytest -q
Variantes para subir de nivel (opcional)
- Jitter: añadir aleatoriedad al delay para evitar “thundering herd”
- Callback on_retry: función que reciba (intento, excepción, delay)
- Soportar
func(*args, **kwargs)en lugar de solofunc() - Límite máximo de delay (
max_delay) - Retry condicional según el mensaje/código de error
Lo que aprendiste
- Diferencia entre fallos temporales vs. fallos definitivos
- Cómo controlar reintentos sin ocultar errores no previstos
- Backoff exponencial y tests deterministas con
sleep_fn - Diseño de una utilidad reusable “tipo producción”
Accede al código completo y a los tests en GitHub para ejecutar y modificar la solución localmente.
Siguiente reto recomendado: Reto #28 — Circuit Breaker simple (cuando dejar de reintentar y “abrir” el circuito).