Fortalece tu código con el paquete Backoff de Python
Últimamente estoy dedicando bastante tiempo a trabajar con APIs comerciales e internas como parte de sistemas más grandes.
Uno de los problemas que me encontraba una y otra vez eran los fallos temporales. Una llamada de red puede fallar porque el servicio esté caído temporalmente, se desconecte una VPN, se exceda una cuota o sin un motivo aparente. En procesos largos, uno de estos fallos puede detener todo el trabajo.
El paquete backoff de Python ha sido útil para gestionar estos fallos sin repetir la misma lógica de reintentos en cada llamada a una API.
¿Por qué Backoff?
Antes de descubrir backoff, muchas veces acababa escribiendo bucles incómodos para repetir llamadas hasta que funcionaran. Algo así:
while True:
try:
response = make_api_call()
if response.status_code == 200:
break
except NetworkError:
continue
except QuotaError:
sleep(60)
continue
except ServerError:
logger.error()
El bucle intentaba la llamada eternamente hasta que tuviera éxito, y permitía una gestión simple de algunos errores. Funcionaba, pero era poco elegante, verboso y difícil de mantener. Además, no incorporaba ningún retraso exponencial, así que durante una caída o fallo temporal podía saturar aún más la red con reintentos.
backoff automatiza los reintentos e incorpora exponential backoff y jitter sin tener que mantener lógica torpe y propensa a errores.
Sustituyó el bucle por una lógica más clara con apenas unas líneas.
Integrar Backoff con llamadas a APIs
Veamos algunos ejemplos de cómo backoff mejora la fiabilidad de llamadas a APIs.
Ejemplo 1: gestión básica de llamadas
Imagina una llamada sencilla a un servicio meteorológico donde pueden aparecer fallos ocasionales por límites de tasa o problemas de conectividad.
Así podrías envolver esa llamada con backoff:
import backoff
import requests
@backoff.on_exception(backoff.expo, requests.exceptions.RequestException, max_tries=5)
def get_weather_data(city):
response = requests.get(
f'http://api.weather.com/v3/wx/forecast/daily/5day?city={city}',
timeout=10,
)
response.raise_for_status()
return response.json()
weather_data = get_weather_data("New York")
Con el decorador @backoff, la llamada a get_weather_data aplicará backoff exponencial automáticamente cuando aparezca una RequestException, reintentando hasta 5 veces.
Ejemplo 2: caídas y límites de API
En este caso usamos una estrategia de espera constante para gestionar estados HTTP como 429, demasiadas peticiones, o 503, servicio no disponible:
@backoff.on_predicate(
backoff.constant,
predicate=lambda response: response.status_code in {429, 503},
interval=5,
max_tries=8,
jitter=None,
)
def fetch_user_data(user_id):
return requests.get(f'http://api.example.com/users/{user_id}', timeout=10)
response = fetch_user_data(12345)
response.raise_for_status()
user_data = response.json()
Aquí usamos un retraso constante de 5 segundos entre reintentos, algo apropiado para manejar límites de tasa o indisponibilidad temporal del servicio.
Ejemplo 3: uso con asyncio
Ahora veamos un ejemplo más realista usando asyncio para hacer 50 llamadas concurrentes a una API, cada una con su propia estrategia de backoff:
import asyncio
import backoff
import aiohttp
@backoff.on_exception(backoff.expo, aiohttp.ClientError, max_tries=5)
async def async_get_user_data(session, user_id):
async with session.get(f'http://api.example.com/users/{user_id}', timeout=10) as response:
response.raise_for_status()
return await response.json()
async def main():
async with aiohttp.ClientSession() as session:
tasks = [async_get_user_data(session, user_id) for user_id in range(50)]
results = await asyncio.gather(*tasks)
return results
# Running the coroutine
user_data_list = asyncio.run(main())
Este ejemplo muestra cómo integrar backoff en un contexto asyncio para manejar muchas llamadas concurrentes. Usando aiohttp, gestionamos problemas de red sin bloquear todo el programa, y cada petición fallida se reintenta hasta 5 veces con backoff exponencial.
Podríamos hacer algo parecido sin backoff usando return_exceptions=True en asyncio.gather, pero tendríamos que escribir código custom para reintentar las funciones que fallaron. Con backoff es mucho más simple.
Funcionalidades adicionales
El paquete backoff ofrece varios decoradores para manejar reintentos y estrategias de espera.
Están bien documentados en el repositorio de backoff en GitHub, pero en resumen:
@backoff.on_exceptionreintenta una función cuando se lanza una excepción concreta.@backoff.on_predicatereintenta una función dependiendo de su valor de retorno.@backoff.runtimeusa el valor devuelto o la excepción lanzada por el método decorado para determinar el comportamiento de backoff.
backoff también permite usar varios decoradores sobre una misma función, dando un control más preciso sobre la gestión de errores.
Jitter
backoff permite añadir aleatoriedad a los intervalos de espera para evitar problemas de estampida.
La función de jitter por defecto, backoff.full_jitter, implementa el algoritmo “Full Jitter”, explicado muy bien en este post del blog de AWS.
Conclusión
El paquete backoff me ha resultado útil para que los procesos largos tengan menos probabilidades de fallar por problemas temporales de API o de red.
En vez de mantener bucles de reintentos en cada llamada a una API, la política de reintentos se queda junto a la función que la necesita. Los decoradores permiten elegir qué errores reintentar, cuánto esperar y cuándo parar.
Esto resulta especialmente útil al hacer muchas peticiones, ya sea de forma síncrona o asíncrona. Un fallo temporal en una petición no tiene por qué interrumpir todo el proceso.
Los reintentos deben configurarse con cuidado. El retraso, el límite y las condiciones de reintento adecuadas dependen de la API y de la operación que se esté realizando.
Notas
Buenas prácticas usando Backoff
- Exponential Backoff & Jitter: aunque una espera constante puede tener sentido en algunos casos, el backoff exponencial con jitter suele ser lo más recomendable para operaciones de red, porque evita problemas de estampida. Conviene configurarlo función a función: el backoff necesario para un error de red no tiene por qué ser el mismo que para un rate limit.
- Logging: registra los reintentos e intégralos en tus herramientas de monitorización para detectar problemas sistémicos pronto.
- Entender los reintentos máximos: busca un equilibrio razonable entre agresividad de reintento y experiencia de usuario. Demasiados reintentos pueden ocultar problemas reales o retrasar la notificación de errores.
- Reintentar operaciones seguras: reintentar peticiones
GETsuele ser seguro. Para peticiones que modifican datos, comoPOSTo pagos, usa claves de idempotencia o una estrategia específica de la API para evitar ejecutar la operación dos veces.
