Patrones implementados
Seis patrones complementarios que cubren todo el ciclo de vida del error en NutriTrack
Jerarquía de Excepciones
Base tipada para todos los errores de dominio: ValidationError, NotFoundError, ExternalServiceError.
Fundamento
Middleware FastAPI
Transforma excepciones internas en respuestas JSON estructuradas con códigos HTTP correctos.
API
Retry + Backoff Exponencial
Decorador para reintentar llamadas a OpenAI con espera progresiva 1s → 2s → 4s.
Resiliencia
Circuit Breaker (Stripe)
Corta automáticamente si Stripe falla 5 veces seguidas. Recuperación automática tras 60s.
Protección
ErrorCollector
Acumula todos los errores de validación del plan nutricional antes de devolver la respuesta.
UX
Degradación Graceful (IA)
Si OpenAI falla, las recomendaciones vuelven a templates estáticos validados. Sin errores visibles.
Fallback
→ Flujo de una petición API con error handling
Request
POST /plans
→
Validate
ErrorCollector
→
OpenAI
retry(3×)
→
Fallback
template estático
→
Stripe CB
circuit breaker
→
200/4xx/5xx
JSON limpio
Jerarquía de Excepciones — errors.py
Base tipada para todo el dominio NutriTrack
📄
nutritrack/core/errors.py
Python
from datetime import datetime, UTC from typing import Any import uuid class NutriTrackError(Exception): """Base para todos los errores de dominio NutriTrack.""" def __init__( self, message: str, code: str = "INTERNAL_ERROR", details: dict[str, Any] | None = None, http_status: int = 500, ) -> None: super().__init__(message) self.code = code self.details = details or {} self.http_status = http_status self.timestamp = datetime.now(UTC).isoformat() self.error_id = str(uuid.uuid4()) def to_dict(self) -> dict: """Serializa para respuesta JSON al cliente.""" return { "error": { "code": self.code, "message": str(self), "details": self.details, "error_id": self.error_id, # para correlacionar con Sentry "timestamp": self.timestamp, } } class ValidationError(NutriTrackError): """Datos de entrada inválidos (HTTP 422).""" def __init__(self, message: str, field: str | None = None, **kwargs): super().__init__(message, code="VALIDATION_ERROR", http_status=422, **kwargs) self.field = field class NotFoundError(NutriTrackError): """Recurso no encontrado (HTTP 404).""" def __init__(self, resource: str, resource_id: str): super().__init__( f"{resource} no encontrado: {resource_id}", code="NOT_FOUND", http_status=404, details={"resource": resource, "id": resource_id}, ) class ExternalServiceError(NutriTrackError): """Fallo en servicio externo (Stripe, OpenAI, SendGrid).""" def __init__(self, message: str, service: str, **kwargs): super().__init__(message, code="EXTERNAL_SERVICE_ERROR", http_status=503, **kwargs) self.service = service self.details["service"] = service class CircuitOpenError(ExternalServiceError): """Circuit breaker abierto — servicio temporalmente no disponible.""" def __init__(self, service: str): super().__init__( f"Servicio {service} temporalmente no disponible. Intenta en unos minutos.", service=service, details={"retry_after_seconds": 60}, )
Middleware FastAPI — respuestas JSON limpias
Ningún stack trace llega al cliente en producción
Por qué importa
Los stack traces en respuestas de producción exponen la arquitectura interna y confunden al frontend.
Este middleware centraliza la transformación de excepciones en respuestas JSON estructuradas.
📄
nutritrack/middleware/error_handler.py
Python
import logging from fastapi import Request from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from nutritrack.core.errors import NutriTrackError logger = logging.getLogger("nutritrack") class ErrorHandlerMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): try: response = await call_next(request) return response except NutriTrackError as e: # Errores de dominio conocidos — log INFO/WARNING if e.http_status >= 500: logger.error( "NutriTrackError %s [%s] path=%s error_id=%s", e.code, e.http_status, request.url.path, e.error_id, exc_info=True, ) else: logger.warning( "NutriTrackError %s [%s] path=%s", e.code, e.http_status, request.url.path, ) return JSONResponse( status_code=e.http_status, content=e.to_dict(), ) except Exception as e: # Error inesperado — log ERROR + Sentry error_id = str(uuid.uuid4()) logger.exception( "Unexpected error path=%s error_id=%s", request.url.path, error_id, ) return JSONResponse( status_code=500, content={ "error": { "code": "INTERNAL_ERROR", "message": "Error interno del servidor", "error_id": error_id, # ← NUNCA devolver str(e) en producción } }, ) # Registrar en la app FastAPI # app.add_middleware(ErrorHandlerMiddleware)
Retry con Backoff Exponencial — llamadas a OpenAI
Reintentos automáticos: 1s → 2s → 4s antes de fallar
📄
nutritrack/utils/retry.py
Python
import asyncio, logging, time from functools import wraps from typing import TypeVar, Callable, Awaitable from openai import RateLimitError, APIConnectionError, APITimeoutError logger = logging.getLogger("nutritrack.retry") T = TypeVar("T") OPENAI_RETRYABLE = (RateLimitError, APIConnectionError, APITimeoutError) def async_retry( max_attempts: int = 3, backoff_factor: float = 2.0, initial_wait: float = 1.0, exceptions: tuple = (Exception,), ): """Decorador async con retry exponencial.""" def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]: @wraps(func) async def wrapper(*args, **kwargs) -> T: for attempt in range(max_attempts): try: return await func(*args, **kwargs) except exceptions as e: remaining = max_attempts - attempt - 1 if remaining == 0: logger.error("[retry] Agotados %d intentos en %s: %s", max_attempts, func.__name__, e) raise wait = initial_wait * (backoff_factor ** attempt) logger.warning( "[retry] Intento %d/%d fallido en %s — esperando %.1fs. Error: %s", attempt + 1, max_attempts, func.__name__, wait, e, ) await asyncio.sleep(wait) return wrapper return decorator # ─── Uso en el servicio de recomendaciones ─────────────────────────────── class AIRecommendationService: def __init__(self, openai_client): self.client = openai_client @async_retry(max_attempts=3, exceptions=OPENAI_RETRYABLE) async def _call_openai(self, prompt: str) -> str: response = await self.client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}], timeout=10, ) return response.choices[0].message.content
Circuit Breaker — protección de Stripe
Evita que los workers se bloqueen en cascada cuando Stripe está caído
Problema real en NutriTrack
Sin circuit breaker, si Stripe responde lento, todos los workers de FastAPI se bloquean esperando
el timeout (30s por defecto). Con 10 workers y 30s de timeout = 300s de parón total.
📄
nutritrack/utils/circuit_breaker.py
Python
from enum import Enum from datetime import datetime, timedelta, UTC from threading import Lock import logging from nutritrack.core.errors import CircuitOpenError logger = logging.getLogger("nutritrack.circuit_breaker") class CircuitState(Enum): CLOSED = "closed" # operación normal OPEN = "open" # rechazando peticiones HALF_OPEN = "half_open" # probando si se recuperó class CircuitBreaker: def __init__( self, name: str, failure_threshold: int = 5, recovery_timeout: int = 60, success_threshold: int = 2, ): self.name = name self.failure_threshold = failure_threshold self.recovery_timeout = timedelta(seconds=recovery_timeout) self.success_threshold = success_threshold self._state = CircuitState.CLOSED self._failure_count = 0 self._success_count = 0 self._last_failure: datetime | None = None self._lock = Lock() def _should_attempt(self) -> bool: if self._state == CircuitState.CLOSED: return True if self._state == CircuitState.OPEN: if datetime.now(UTC) - self._last_failure > self.recovery_timeout: self._state = CircuitState.HALF_OPEN self._success_count = 0 logger.info("[CB:%s] → HALF_OPEN — probando recuperación", self.name) return True return False return True # HALF_OPEN def call(self, func, *args, **kwargs): with self._lock: if not self._should_attempt(): raise CircuitOpenError(self.name) try: result = func(*args, **kwargs) self._on_success() return result except Exception as e: self._on_failure() raise def _on_success(self): with self._lock: self._failure_count = 0 if self._state == CircuitState.HALF_OPEN: self._success_count += 1 if self._success_count >= self.success_threshold: self._state = CircuitState.CLOSED logger.info("[CB:%s] → CLOSED — servicio recuperado ✓", self.name) def _on_failure(self): with self._lock: self._failure_count += 1 self._last_failure = datetime.now(UTC) if self._failure_count >= self.failure_threshold: self._state = CircuitState.OPEN logger.error( "[CB:%s] → OPEN — %d fallos consecutivos", self.name, self._failure_count, ) # ─── Instancia global para Stripe ──────────────────────────────────────── stripe_circuit = CircuitBreaker(name="stripe", failure_threshold=5, recovery_timeout=60)
ErrorCollector — validación de planes nutricionales
Devuelve todos los errores de validación en una sola respuesta
📄
nutritrack/validators/plan_validator.py
Python
from dataclasses import dataclass from nutritrack.core.errors import ValidationError @dataclass class PlanValidationError: field: str message: str class ErrorCollector: """Acumula errores de validación — no lanza hasta el final.""" def __init__(self): self._errors: list[PlanValidationError] = [] def add(self, field: str, message: str) -> None: self._errors.append(PlanValidationError(field=field, message=message)) def has_errors(self) -> bool: return bool(self._errors) def raise_if_any(self) -> None: if self._errors: raise ValidationError( f"Plan nutricional inválido: {len(self._errors)} error(s)", details={ "fields": [ {"field": e.field, "message": e.message} for e in self._errors ] }, ) def validate_nutrition_plan(data: dict) -> None: """Valida un plan completo, acumulando todos los errores.""" errors = ErrorCollector() # ── Calorías ──────────────────────────────────────────────────────── calories = data.get("calories_target") if calories is None: errors.add("calories_target", "Obligatorio") elif not (500 <= calories <= 5000): errors.add("calories_target", "Debe estar entre 500 y 5000 kcal") # ── Macros (deben sumar 100%) ───────────────────────────────────── carbs = data.get("carbs_pct", 0) protein= data.get("protein_pct", 0) fat = data.get("fat_pct", 0) total = carbs + protein + fat if not (99 <= total <= 101): errors.add("macros", f"Macros suman {total}%, deben sumar 100%") # ── Comidas por día ─────────────────────────────────────────────── meals = data.get("meals_per_day") if meals is None: errors.add("meals_per_day", "Obligatorio") elif not (1 <= meals <= 8): errors.add("meals_per_day", "Debe estar entre 1 y 8 comidas") # ── Alérgenos válidos ───────────────────────────────────────────── valid_allergens = {"gluten", "lactosa", "frutos_secos", "huevo", "marisco"} unknown = set(data.get("allergens", [])) - valid_allergens if unknown: errors.add("allergens", f"Alérgenos desconocidos: {', '.join(unknown)}") errors.raise_if_any() # ← lanza ValidationError con TODOS los campos
Degradación Graceful — recomendaciones IA
Si OpenAI falla, el usuario recibe recomendaciones de templates sin ver el error
Principio
Una función rota que el usuario NO nota es mejor que una función perfecta que bloquea la app.
Los templates estáticos son validados por nutricionistas y correctos para el 80% de los casos.
📄
nutritrack/services/recommendations.py
Python
import logging from nutritrack.utils.retry import async_retry, OPENAI_RETRYABLE from nutritrack.data.templates import STATIC_RECOMMENDATIONS logger = logging.getLogger("nutritrack.recommendations") class RecommendationService: def __init__(self, openai_client): self.client = openai_client @async_retry(max_attempts=3, exceptions=OPENAI_RETRYABLE) async def _ai_recommend(self, profile: dict) -> dict: """Llamada real a OpenAI — falla si hay error de red o rate limit.""" prompt = self._build_prompt(profile) raw = await self.client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}], ) return self._parse_response(raw.choices[0].message.content) async def get_recommendations( self, patient_id: str, profile: dict, ) -> dict: """ Obtiene recomendaciones con degradación graceful: 1. Intenta OpenAI (3 intentos con backoff) 2. Si falla → template estático validado """ try: result = await self._ai_recommend(profile) return {**result, "source": "ai"} except Exception as e: logger.error( "[recommendations] IA no disponible para patient=%s, usando template. Causa: %s", patient_id, e, ) goal = profile.get("goal", "maintenance") template = STATIC_RECOMMENDATIONS.get(goal, STATIC_RECOMMENDATIONS["maintenance"]) return {**template, "source": "template", "ai_available": False} # ↑ El frontend muestra "Recomendación estándar" en vez de error def _build_prompt(self, profile: dict) -> str: return ( f"Genera recomendaciones nutricionales para paciente con perfil: {profile}. " "Formato JSON con campos: meals, supplements, hydration_ml." )
Mapa de errores → respuestas HTTP
Referencia rápida para el equipo de frontend de NutriTrack
| Excepción | HTTP | code | Cuándo ocurre | Acción frontend |
|---|---|---|---|---|
ValidationError |
422 | VALIDATION_ERROR |
Campos inválidos en formulario | Mostrar errores por campo en el form |
NotFoundError |
404 | NOT_FOUND |
Paciente o plan no existe | Redirigir al listado |
CircuitOpenError |
503 | EXTERNAL_SERVICE_ERROR |
Stripe caído (>5 fallos) | Banner "Pagos temporalmente suspendidos" |
ExternalServiceError |
503 | EXTERNAL_SERVICE_ERROR |
SendGrid / OpenAI error puntual | Toast "Servicio no disponible" + retry |
Exception (inesperado) |
500 | INTERNAL_ERROR |
Bug no controlado | Mostrar error_id para soporte |
Cliente TypeScript — manejo en React
Result type + consumo tipado de la API
📄
src/lib/api-client.ts
TypeScript
// Result type: manejo explícito sin excepciones volando por el aire type Result<T> = | { ok: true; data: T } | { ok: false; code: string; message: string; fields?: FieldError[] }; interface FieldError { field: string; message: string; } async function apiCall<T>( url: string, options?: RequestInit, ): Promise<Result<T>> { try { const res = await fetch(url, options); const body = await res.json(); if (res.ok) { return { ok: true, data: body as T }; } // Errores estructurados del backend const err = body.error; return { ok: false, code: err.code, message: err.message, fields: err.details?.fields, }; } catch (e) { // Error de red (sin respuesta del servidor) return { ok: false, code: "NETWORK_ERROR", message: "Sin conexión" }; } } // ─── Uso en el componente PlanForm ────────────────────────────────── async function submitPlan(planData: PlanFormData) { const result = await apiCall<NutritionPlan>("/api/plans", { method: "POST", body: JSON.stringify(planData), }); if (!result.ok) { switch (result.code) { case "VALIDATION_ERROR": setFieldErrors(result.fields ?? []); // marcar campos en rojo break; case "EXTERNAL_SERVICE_ERROR": showToast("Servicio temporalmente no disponible. Intenta en unos minutos."); break; default: showToast(`Error inesperado (${result.message})`); } return; } onSuccess(result.data); }
Checklist de implementación
Puntos a verificar antes de desplegar en producción
✓ Backend Python
☐
NutriTrackError como base de todo error☐
ErrorHandlerMiddleware registrado en app☐
@async_retry en todas las llamadas a OpenAI☐
stripe_circuit wrapeando cobros☐
validate_nutrition_plan usa ErrorCollector☐
get_recommendations tiene fallback a templates☐ No hay
except Exception: pass silencioso☐
error_id correlacionado en Sentry
✓ Frontend TypeScript
☐
apiCall<T> devuelve siempre Result<T>☐
VALIDATION_ERROR → errores en campos del form☐
EXTERNAL_SERVICE_ERROR → banner informativo☐
NETWORK_ERROR → mensaje de sin conexión☐ Ningún
.json() sin try/catch☐
error_id visible en UI para soporte☐ Unhandled promise rejections capturadas globalmente
☐ Loading states bloqueados durante retry