● Web · Patrones de Referencia

Patrones de Manejo de Errores

Implementación robusta para NutriTrack SaaS — Python (FastAPI) + TypeScript (React)
Stack: Python · FastAPI · TypeScript
Nivel: Intermedio
Patrones: 6
Servicios: Stripe · OpenAI · SendGrid
🔤

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

1

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},
        )
2

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)
3

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
4

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)
5

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
6

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
TS

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