Cuando tu API retorna un error, el consumidor necesita dos cosas: saber qué pasó y saber qué hacer. La mayoría de las APIs falla en entregar ambas.
El patrón más común que veo en codebases: un catch genérico que retorna 500 con un mensaje vago. El consumidor no sabe si es problema suyo, de la red o del servidor. No puede hacer retry con seguridad. No puede reportar el bug con contexto.
El formato de error que funciona
Estandariza la respuesta de error. Siempre el mismo shape, independientemente del tipo de fallo:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Email inválido",
"details": [
{
"field": "email",
"message": "Formato de email inválido",
"value": "marc@"
}
],
"request_id": "req_abc123",
"timestamp": "2026-03-22T14:30:00Z"
}
}
code es un string legible por máquina. message es legible por humanos. details contiene contexto específico. request_id permite correlacionar con logs. timestamp ayuda en debugging temporal.
Mapa de status codes
Cada status code tiene un significado. Úsalos correctamente:
- 400 Bad Request: el cliente envió datos inválidos. Incluye qué está mal.
- 401 Unauthorized: el cliente no se autenticó. Retorna headers de autenticación.
- 403 Forbidden: el cliente se autenticó pero no tiene permiso.
- 404 Not Found: el recurso no existe. Diferéncialo de 403 en recursos sensibles.
- 409 Conflict: conflicto de estado, email duplicado, versión desactualizada.
- 422 Unprocessable: datos validados pero con reglas de negocio violadas.
- 429 Too Many Requests: rate limit. Retorna headers
Retry-After. - 500 Internal Error: fallo en el servidor. Nunca expongas stack traces.
- 503 Service Unavailable: servicio temporalmente indisponible.
Implementación en Express/Nest
Un middleware de error centralizado evita repetición y garantiza consistencia:
class AppError extends Error {
constructor(
public readonly code: string,
public readonly statusCode: number,
message: string,
public readonly details?: unknown[]
) {
super(message)
}
}
// Middleware de error
function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details,
request_id: req.id,
timestamp: new Date().toISOString()
}
})
}
console.error('Unhandled error:', err)
return res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'Error interno del servidor',
request_id: req.id,
timestamp: new Date().toISOString()
}
})
}
Qué no exponer en los errores
Nunca retornes stack traces, paths de archivos ni detalles de implementación. En producción, esos datos son vectores de ataque. Registra todo internamente y retorna solo lo necesario para que el consumidor corrija el problema.
Para errores de validación, muestra los campos con problemas. Para errores de autenticación, no digas si el usuario existe o no. Para errores de base de datos, retorna un mensaje genérico y registra el detalle internamente.
Logging de errores
Cada error debe generar un log con contexto: request_id, user_id, endpoint, payload (sanitizado) y stack trace completa. Herramientas como Sentry o Datadog agregan esos logs y permiten buscar por código de error, frecuencia e impacto.
El patrón request_id en el header X-Request-Id permite al consumidor reportar exactamente qué request falló y al equipo encontrar el log correspondiente.
¿Te gustó el contenido?
Construyo productos web y soluciones con IA de la manera correcta — arquitectura sólida, código sostenible y entrega real.
Hablemos