πΎ NutriPaw API β Stack: Next.js + FastAPI + Go
Patrones de Manejo de Errores
para Produccion
Guia de referencia tecnica para el equipo de ingenieria de NutriPaw. Cubre errores tipados, jerarquias, fronteras, reintentos y mensajes orientados al usuario en los tres lenguajes del stack.
π 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
FAIL
~550ms
β
Intento 2
FAIL
FAIL
~1.1s
β
Intento 3
OK
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