πŸ“ Principios

5 Principios Core

1

Fallar rapido y visible

Surfacear errores en la frontera donde ocurren. No propagar silenciosamente hacia capas superiores.

2

Errores tipados como valores

Los errores son valores de primera clase con estructura: { code, message, details }

3

Usuario β‰  Desarrollador

Mensaje amigable al usuario, contexto completo (stack, request ID, user ID) en logs de servidor.

4

Nunca tragarse errores

Todo bloque catch debe: manejar, relanzar, o loguear. Nunca un catch vacio.

5

Errores como contrato de API

Documentar todos los codigos de error que un cliente puede recibir. Consistencia en el envelope.

πŸ—ΊοΈ Mapa

Contrato de Errores de la API

Clase de Error Codigo HTTP Mensaje Usuario Retriable
NotFoundError NOT_FOUND 404 El recurso solicitado no existe. ❌
UnauthorizedError UNAUTHORIZED 401 Inicia sesion para continuar. ❌
ForbiddenError FORBIDDEN 403 No tienes permiso para esta accion. ❌
ValidationError VALIDATION_ERROR 422 Revisa los datos del formulario. ❌
RateLimitError RATE_LIMITED 429 Demasiadas peticiones. Espera un momento. βœ… (con jitter)
UpstreamError UPSTREAM_ERROR 502 Servicio externo no disponible. Reintentando... βœ… (max 3)
InternalError INTERNAL_ERROR 500 Algo salio mal. Intentalo de nuevo. βœ… (1 vez)
PaymentError PAYMENT_FAILED 402 Pago rechazado. Verifica tu metodo de pago. ❌
πŸ”· TypeScript

Jerarquia de Errores (Next.js App Router)

Clases Base

πŸ“„ lib/errors.ts β€” NutriPaw API
// ─── Base Error ────────────────────────────────────────────────────────────────
export class AppError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly statusCode: number = 500,
    public readonly details?: unknown,
  ) {
    super(message)
    this.name = this.constructor.name
    Object.setPrototypeOf(this, new.target.prototype)
  }
}

// ─── Dominio NutriPaw ───────────────────────────────────────────────────────────
export class PetNotFoundError extends AppError {
  constructor(petId: string) {
    super(`Mascota no encontrada: ${petId}`, 'NOT_FOUND', 404)
  }
}

export class ValidationError extends AppError {
  constructor(
    message: string,
    details: { field: string; message: string }[]
  ) {
    super(message, 'VALIDATION_ERROR', 422, details)
  }
}

export class PaymentError extends AppError {
  constructor(public readonly stripeCode: string) {
    super(`Pago rechazado: ${stripeCode}`, 'PAYMENT_FAILED', 402)
  }
}

export class OpenAIUpstreamError extends AppError {
  constructor(cause: unknown) {
    super('Servicio de recomendaciones no disponible', 'UPSTREAM_ERROR', 502)
    this.cause = cause // mantiene el error original para Sentry
  }
}

Patron Result (no-throw) β€” llamadas a OpenAI

Flujo Result<T, E>
Llamada
fetchRecs()
β†’
Promise
Result<Rec[]>
β†’
ok = true
result.value
|
ok = false
result.error
πŸ“„ lib/ml-client.ts β€” Servicio Recomendaciones
type Result<T, E = AppError> =
  | { ok: true;  value: T }
  | { ok: false; error: E }

async function fetchRecommendations(
  petId: string
): Promise<Result<Recommendation[]>> {
  try {
    const recs = await withRetry(
      () => openai.complete({ petId }),
      { maxAttempts: 3, retryIf: (e) => e.status >= 500 }
    )
    return { ok: true, value: recs }
  } catch (e) {
    // Log contexto completo en Sentry, error limpio al caller
    Sentry.captureException(e, { extra: { petId } })
    return { ok: false, error: new OpenAIUpstreamError(e) }
  }
}

// En el Route Handler β€” TypeScript conoce el tipo en cada rama
const result = await fetchRecommendations('pet-abc123')
if (!result.ok) {
  return handleApiError(result.error) // result.error: OpenAIUpstreamError
}
console.log(result.value) // result.value: Recommendation[]

Handler Global de API (App Router)

πŸ“„ app/api/[...]/route.ts β€” Envelope Estandar
function handleApiError(error: unknown): NextResponse {
  if (error instanceof AppError) {
    return NextResponse.json(
      { error: { code: error.code, message: getUserMessage(error.code) } },
      { status: error.statusCode }
    )
  }
  // Error inesperado β€” NUNCA exponer detalles al cliente
  console.error('[NutriPaw] Unexpected error:', error)
  return NextResponse.json(
    { error: { code: 'INTERNAL_ERROR', message: 'Algo saliΓ³ mal. IntΓ©ntalo de nuevo.' } },
    { status: 500 }
  )
}
🐍 Python

FastAPI β€” Servicio de Recomendaciones ML

Jerarquia de Excepciones

πŸ“„ app/exceptions.py
class AppError(Exception):
    """Error base de NutriPaw β€” siempre tiene code y status_code."""
    def __init__(self, message: str, code: str, status_code: int = 500):
        super().__init__(message)
        self.code = code
        self.status_code = status_code

class ModelNotReadyError(AppError):
    def __init__(self, model_id: str):
        super().__init__(
            f"Modelo {model_id} no listo (warmup pendiente)",
            "MODEL_NOT_READY", 503
        )

class OpenAIQuotaError(AppError):
    def __init__(self, retry_after: int):
        super().__init__(
            f"Cuota OpenAI agotada, reintentar en {retry_after}s",
            "RATE_LIMITED", 429
        )
        self.retry_after = retry_after

# Handler global β€” CORRIGE el incidente #3 de NutriPaw
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
    return JSONResponse(
        status_code=exc.status_code,
        content={"error": {"code": exc.code, "message": get_user_message(exc.code)}}
    )

@app.exception_handler(Exception)
async def generic_error_handler(request: Request, exc: Exception) -> JSONResponse:
    # Contexto COMPLETO al log β€” nada al cliente
    logger.exception("Error inesperado", exc_info=exc,
                      extra={"path": request.url.path})
    return JSONResponse(
        status_code=500,
        content={"error": {"code": "INTERNAL_ERROR",
                              "message": "Algo saliΓ³ mal en el servidor."}}
    )
🐹 Go

Servicio de Pagos y Suscripciones (Stripe)

Errores Centinela y Error Wrapping

πŸ“„ domain/errors.go β€” CORRIGE incidente #1 (Stripe)
package domain

import "errors"

// Errores centinela β€” permiten errors.Is() en cualquier capa
var (
    ErrNotFound      = errors.New("not found")
    ErrUnauthorized  = errors.New("unauthorized")
    ErrPaymentFailed = errors.New("payment failed")
    ErrConflict      = errors.New("conflict")
)

// Repositorio: siempre wrappear con contexto β€” nunca perder el error original
func (r *SubscriptionRepo) FindByUserID(
    ctx context.Context, userID string,
) (*Subscription, error) {
    sub, err := r.db.QueryRow(ctx,
        "SELECT * FROM subscriptions WHERE user_id = $1", userID)
    if errors.Is(err, sql.ErrNoRows) {
        return nil, fmt.Errorf("subscription user=%s: %w", userID, ErrNotFound)
    }
    if err != nil {
        return nil, fmt.Errorf("querying subscription user=%s: %w", userID, err)
    }
    return sub, nil
}

// Handler HTTP: desenvuelve el error para decidir la respuesta
func (h *PaymentHandler) CreateSubscription(
    w http.ResponseWriter, r *http.Request,
) {
    err := h.svc.Create(r.Context(), chi.URLParam(r, "userID"))
    if err != nil {
        switch {
        case errors.Is(err, domain.ErrPaymentFailed):
            writeError(w, 402, "payment_failed", "Pago rechazado.")
        case errors.Is(err, domain.ErrNotFound):
            writeError(w, 404, "not_found", "Usuario no encontrado.")
        default:
            // Log contexto COMPLETO β€” slog incluye el stack
            slog.Error("unexpected payment error", "err", err, "user", userID)
            writeError(w, 500, "internal_error", "Error inesperado.")
        }
        return
    }
    writeJSON(w, 201, "subscription created")
}
πŸ”„ Retry

Exponential Backoff con Jitter β€” OpenAI & Stripe

Timeline de Reintentos (base 500ms, max 10s)
βœ•
Intento 1
FAIL
~550ms
βœ•
Intento 2
FAIL
~1.1s
βœ“
Intento 3
OK
delay = min(baseDelay Γ— 2^(attempt-1) + random(0, baseDelay), maxDelay)
πŸ“„ lib/retry.ts β€” Solo reintenta errores 5xx, nunca 4xx
interface RetryOptions {
  maxAttempts?: number    // default: 3
  baseDelayMs?: number    // default: 500ms
  maxDelayMs?:  number    // default: 10_000ms
  retryIf?: (e: unknown) => boolean
}

async function withRetry<T>(
  fn: () => Promise<T>,
  opts: RetryOptions = {}
): Promise<T> {
  const { maxAttempts=3, baseDelayMs=500, maxDelayMs=10_000, retryIf=()=>true } = opts
  let last: unknown

  for (let i = 1; i <= maxAttempts; i++) {
    try {
      return await fn()
    } catch (e) {
      last = e
      if (i === maxAttempts || !retryIf(e)) throw e
      const jitter = Math.random() * baseDelayMs
      const delay  = Math.min(baseDelayMs * 2 ** (i-1) + jitter, maxDelayMs)
      await new Promise(r => setTimeout(r, delay))
    }
  }
  throw last
}

// Uso en NutriPaw β€” solo reintenta errores de servidor (5xx), nunca 4xx
const recs = await withRetry(
  () => openai.complete({ petId: 'pet-abc123' }),
  { retryIf: (e) => !(e instanceof AppError && e.statusCode < 500) }
)
πŸ’¬ Mensajes

Mensajes de Usuario vs Desarrollador

πŸ“„ lib/user-messages.ts
// Mensajes para el usuario final β€” NUNCA exponer detalles tecnicos
const USER_MESSAGES: Record<string, string> = {
  NOT_FOUND:        'El recurso solicitado no existe.',
  UNAUTHORIZED:    'Inicia sesiΓ³n para continuar.',
  FORBIDDEN:       'No tienes permiso para esta acciΓ³n.',
  VALIDATION_ERROR:'Revisa los datos del formulario.',
  RATE_LIMITED:    'Demasiadas peticiones. Espera un momento.',
  PAYMENT_FAILED:  'Pago rechazado. Verifica tu mΓ©todo de pago.',
  UPSTREAM_ERROR:  'Servicio no disponible. Reintentando...',
  INTERNAL_ERROR:  'Algo saliΓ³ mal. IntΓ©ntalo de nuevo mΓ‘s tarde.',
  MODEL_NOT_READY: 'Preparando recomendaciones. Vuelve en un momento.',
}

export function getUserMessage(code: string): string {
  return USER_MESSAGES[code] ?? USER_MESSAGES.INTERNAL_ERROR
}

// En Sentry/PostHog: loguear todo el contexto
function logServerError(error: unknown, ctx: ErrorContext) {
  Sentry.captureException(error, {
    extra: { ...ctx, rawError: error },
    tags:  { service: 'nutripaw-api', version: process.env.NEXT_PUBLIC_VERSION }
  })
}
βœ… Checklist

Pre-Merge β€” Error Handling Review

NutriPaw API β€” Checklist obligatorio antes de hacer merge 0 / 8 completados