Resiliencia en Python: Reintentos y Backoff Exponencial
Patrones de resiliencia para aplicaciones Python: reintentos automáticos, backoff exponencial con jitter, timeouts y decoradores tolerantes a fallos usando tenacity. Ideal para servicios que consumen APIs externas o microservicios con dependencias inestables.
Descarga abierta · sin registro · para Python, tenacity, httpx
""" resilient_clients.py — NutriSync AI
Clientes resilientes para los tres servicios externos de NutriSync AI. Aplica patrones de tenacity: reintentos, backoff exponencial con jitter, timeouts y logging de cada intento.
Dependencias: tenacity>=8, httpx>=0.27, openai>=1.30
Uso: from resilient_clients import openai_complete, lookup_nutrients, send_report_email """
from future import annotations
import logging import random import time from dataclasses import dataclass, field from typing import Any
import httpx from tenacity import ( RetryCallState, before_sleep_log, retry, retry_if_exception_type, retry_if_result, stop_after_attempt, stop_after_delay, wait_exponential_jitter, )
---------------------------------------------------------------------------
Logging
---------------------------------------------------------------------------
logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s — %(message)s", ) logger = logging.getLogger("nutrisync.resilience")
---------------------------------------------------------------------------
Métricas en memoria (sustitúyelas por Prometheus/StatsD en producción)
---------------------------------------------------------------------------
@dataclass class RetryMetrics: """Conteo de intentos por servicio.""" attempts: dict[str, int] = field(default_factory=dict) failures: dict[str, int] = field(default_factory=dict)
def record_attempt(self, service: str) -> None:
self.attempts[service] = self.attempts.get(service, 0) + 1
def record_failure(self, service: str) -> None:
self.failures[service] = self.failures.get(service, 0) + 1
def summary(self) -> dict[str, Any]:
return {"attempts": dict(self.attempts), "failures": dict(self.failures)}
metrics = RetryMetrics()
---------------------------------------------------------------------------
Excepciones retryables comunes
---------------------------------------------------------------------------
NETWORK_ERRORS = ( ConnectionError, TimeoutError, httpx.ConnectError, httpx.ReadTimeout, httpx.ConnectTimeout, httpx.RemoteProtocolError, )
RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
def _is_retryable_response(response: httpx.Response) -> bool: """Devuelve True si el código HTTP indica un error transitorio.""" return response.status_code in RETRY_STATUS_CODES
def _make_before_sleep(service: str): """Callback que registra cada reintento y actualiza métricas.""" def _log(retry_state: RetryCallState) -> None: metrics.record_attempt(service) attempt_n = retry_state.attempt_number wait_s = retry_state.next_action.sleep if retry_state.next_action else 0 # type: ignore[union-attr] exc = retry_state.outcome.exception() if retry_state.outcome else None logger.warning( "[%s] Intento %d fallido — esperando %.2fs. Error: %s", service, attempt_n, wait_s, exc or "respuesta no retryable", ) return _log
===========================================================================
CLIENTE 1 — OpenAI GPT-4o (generación de planes nutricionales)
===========================================================================
Errores relevantes:
- openai.RateLimitError → 429, reintentable con backoff largo
- openai.APITimeoutError → timeout de red, reintentable
- openai.APIConnectionError → red inestable, reintentable
- openai.BadRequestError → prompt inválido, NO reintentable
- openai.AuthenticationError → API key mala, NO reintentable
try: import openai
OPENAI_RETRYABLE = (
openai.RateLimitError,
openai.APITimeoutError,
openai.APIConnectionError,
)
except ImportError: # Fallback si openai no está instalado en este entorno de demo OPENAI_RETRYABLE = (ConnectionError, TimeoutError) # type: ignore[assignment] openai = None # type: ignore[assignment]
def _openai_retry_decorator(): return retry( retry=retry_if_exception_type(OPENAI_RETRYABLE), # Máximo 4 intentos O 90 s totales, lo que llegue primero stop=stop_after_attempt(4) | stop_after_delay(90), # Backoff: 2 s → 4 s → 8 s … con ±25 % de jitter wait=wait_exponential_jitter(initial=2, max=30, jitter=5), before_sleep=_make_before_sleep("openai-gpt4o"), reraise=True, )
@_openai_retry_decorator() def openai_complete( messages: list[dict[str, str]], model: str = "gpt-4o", temperature: float = 0.4, ) -> str: """ Llama a la API de OpenAI con reintentos automáticos en errores transitorios.
Args:
messages: Lista de mensajes en formato OpenAI chat.
model: Modelo a usar (por defecto gpt-4o).
temperature: Temperatura de generación.
Returns:
Texto de la respuesta del modelo.
Raises:
openai.AuthenticationError: Si la API key es inválida (no se reintenta).
openai.BadRequestError: Si el prompt es inválido (no se reintenta).
openai.RateLimitError: Si se agota el presupuesto de reintentos.
"""
metrics.record_attempt("openai-gpt4o")
if openai is None:
raise RuntimeError("openai no instalado — demo mode")
client = openai.OpenAI() # Lee OPENAI_API_KEY del entorno
response = client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
timeout=30,
)
return response.choices[0].message.content or ""
---------------------------------------------------------------------------
Ejemplo de uso — generación de plan nutricional
---------------------------------------------------------------------------
PLAN_PROMPT = [ { "role": "system", "content": ( "Eres NutriSync, un nutricionista IA. " "Genera planes de comida semanales detallados y científicamente fundamentados." ), }, { "role": "user", "content": ( "Genera un plan de 5 días para usuario masculino 32 años, 78 kg, " "objetivo: déficit calórico moderado (-300 kcal/día). " "Sin gluten. Formato JSON con: día, desayuno, almuerzo, cena, snack, kcal_total." ), }, ]
===========================================================================
CLIENTE 2 — Edamam Food API (lookup de nutrientes)
===========================================================================
EDAMAM_BASE_URL = "https://api.edamam.com/api/food-database/v2/parser"
def _edamam_retry_decorator(): return retry( retry=( retry_if_exception_type(NETWORK_ERRORS) | retry_if_result(_is_retryable_response) ), stop=stop_after_attempt(5) | stop_after_delay(60), wait=wait_exponential_jitter(initial=1, max=20, jitter=3), before_sleep=_make_before_sleep("edamam-food-api"), reraise=True, )
@_edamam_retry_decorator() def _edamam_raw_request(ingredient: str, app_id: str, app_key: str) -> httpx.Response: """Petición HTTP cruda a Edamam (reintentable).""" metrics.record_attempt("edamam-food-api") response = httpx.get( EDAMAM_BASE_URL, params={ "ingr": ingredient, "app_id": app_id, "app_key": app_key, "nutrition-type": "cooking", }, timeout=15, ) return response
def lookup_nutrients( ingredient: str, app_id: str = "EDAMAM_APP_ID", app_key: str = "EDAMAM_APP_KEY", ) -> dict[str, Any]: """ Consulta el perfil nutricional de un ingrediente en Edamam.
Args:
ingredient: Nombre del ingrediente (ej. "100g chicken breast").
app_id: Credencial Edamam Application ID.
app_key: Credencial Edamam Application Key.
Returns:
Dict con calories, protein, fat, carbs (por 100 g).
Raises:
httpx.HTTPStatusError: Si la respuesta es 4xx no reintentable.
"""
response = _edamam_raw_request(ingredient, app_id, app_key)
if response.status_code == 401:
raise httpx.HTTPStatusError(
"Credenciales Edamam inválidas — no se reintenta",
request=response.request,
response=response,
)
response.raise_for_status()
data = response.json()
# Extraer primer resultado
hint = data.get("hints", [{}])[0]
nutrients = hint.get("food", {}).get("nutrients", {})
return {
"ingredient": ingredient,
"calories_kcal": nutrients.get("ENERC_KCAL", 0),
"protein_g": nutrients.get("PROCNT", 0),
"fat_g": nutrients.get("FAT", 0),
"carbs_g": nutrients.get("CHOCDF", 0),
"fiber_g": nutrients.get("FIBTG", 0),
}
===========================================================================
CLIENTE 3 — SendGrid (envío de informes semanales)
===========================================================================
SENDGRID_API_URL = "https://api.sendgrid.com/v3/mail/send"
def _sendgrid_retry_decorator(): return retry( retry=( retry_if_exception_type(NETWORK_ERRORS) | retry_if_result(lambda r: r.status_code in {429, 500, 502, 503, 504}) ), stop=stop_after_attempt(4) | stop_after_delay(45), wait=wait_exponential_jitter(initial=2, max=15, jitter=2), before_sleep=_make_before_sleep("sendgrid"), reraise=True, )
@_sendgrid_retry_decorator() def _sendgrid_raw_send(payload: dict[str, Any], api_key: str) -> httpx.Response: """Envío HTTP crudo a SendGrid (reintentable).""" metrics.record_attempt("sendgrid") response = httpx.post( SENDGRID_API_URL, json=payload, headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, timeout=20, ) return response
def send_report_email( to_email: str, subject: str, html_content: str, from_email: str = "hola@nutrisync.ai", api_key: str = "SG.REPLACE_ME", ) -> bool: """ Envía el informe nutricional semanal por email con reintentos automáticos.
Args:
to_email: Destinatario.
subject: Asunto del email.
html_content: Cuerpo HTML del informe.
from_email: Remitente verificado en SendGrid.
api_key: API Key de SendGrid.
Returns:
True si el email se envió correctamente (2xx).
Raises:
httpx.HTTPStatusError: En error 4xx (credenciales/payload inválido).
"""
payload = {
"personalizations": [{"to": [{"email": to_email}]}],
"from": {"email": from_email},
"subject": subject,
"content": [{"type": "text/html", "value": html_content}],
}
response = _sendgrid_raw_send(payload, api_key)
# 401/403 son permanentes — no se reintentan
if response.status_code in {401, 403}:
raise httpx.HTTPStatusError(
f"SendGrid auth error {response.status_code}",
request=response.request,
response=response,
)
return response.status_code in {200, 202}
===========================================================================
DEMO LOCAL — simula fallos y reintentos sin APIs reales
===========================================================================
def _demo_simulate_flaky_service(service_name: str, fail_n_times: int = 2) -> None: """Demuestra el comportamiento de reintento sin llamadas reales a la red.""" attempt = [0]
@retry(
retry=retry_if_exception_type(ConnectionError),
stop=stop_after_attempt(fail_n_times + 1),
wait=wait_exponential_jitter(initial=0.1, max=1.0, jitter=0.05),
before_sleep=_make_before_sleep(service_name),
reraise=False,
)
def _flaky_call() -> str:
attempt[0] += 1
if attempt[0] <= fail_n_times:
logger.info("[%s] Simulando fallo transitorio #%d", service_name, attempt[0])
raise ConnectionError(f"Fallo transitorio simulado #{attempt[0]}")
logger.info("[%s] Llamada exitosa en intento #%d", service_name, attempt[0])
return f"OK tras {attempt[0]} intentos"
result = _flaky_call()
logger.info("[%s] Resultado final: %s", service_name, result)
if name == "main": print("\n" + "=" * 60) print(" NutriSync AI — Demo de Resiliencia con tenacity") print("=" * 60 + "\n")
print(">> Simulando servicio inestable: OpenAI GPT-4o (2 fallos transitorios)")
_demo_simulate_flaky_service("openai-gpt4o", fail_n_times=2)
print("\n>> Simulando servicio inestable: Edamam Food API (1 fallo transitorio)")
_demo_simulate_flaky_service("edamam-food-api", fail_n_times=1)
print("\n>> Simulando servicio inestable: SendGrid (3 fallos transitorios)")
_demo_simulate_flaky_service("sendgrid", fail_n_times=3)
print("\n>> Resumen de métricas de reintentos:")
import json
print(json.dumps(metrics.summary(), indent=2))
print("\n" + "=" * 60)
print(" Módulo listo para integrar en FastAPI de NutriSync AI")
print(" Import: from resilient_clients import openai_complete,")
print(" lookup_nutrients, send_report_email")
print("=" * 60 + "\n")
// qué_hace
Proporciona patrones listos para usar que hacen que las llamadas a servicios externos en Python fallen de forma elegante ante errores transitorios.
// cómo_lo_hace
Aplica decoradores de tenacity con backoff exponencial, jitter aleatorio y filtrado por tipo de excepción o código HTTP para reintentar solo los errores recuperables.
// ejemplo_de_uso
Cuando integras APIs de terceros como pagos o SMS que pueden fallar esporádicamente. Ej.: envuelves la llamada a Stripe con un decorador tenacity que reintenta 3 veces con backoff exponencial solo ante errores 429 o 503.
// plataformas
// opiniones_de_la_comunidad
Opiniones
Cargando opiniones…
// pase_cultiva_ia
Llévate todo el arsenal con el Pase
Todas las skills, prompts y automatizaciones del catálogo en un único archivo, listas para usar: un pago, acceso de por vida y las novedades que añadamos. Sin suscripción.
Pago único · IVA incluido · pago seguro con Stripe.
Acceso inmediato · si no es lo que esperabas, te devolvemos los 10 €.