Por qué estandarizar errores y validaciones
Un manejo consistente de errores reduce ambigüedad para clientes (web, móvil, integraciones) y simplifica observabilidad en el servidor. La meta es que cualquier respuesta de error sea: predecible (misma forma), accionable (indica qué corregir), trazable (permite correlación), y segura (no filtra detalles internos).
Formato estándar de error (contrato)
Define un único “sobre” (envelope) para errores, independientemente del endpoint. Un formato práctico incluye: código interno estable, mensaje legible, detalles por campo, traceId/correlationId y enlaces a documentación.
Esquema recomendado
{ "error": { "code": "USR_EMAIL_INVALID", "message": "El email no tiene un formato válido.", "status": 422, "traceId": "01HZY...", "details": [ { "field": "email", "issue": "format", "message": "Debe ser un email válido.", "value": "foo@" } ], "links": { "about": "https://api.ejemplo.com/docs/errors#USR_EMAIL_INVALID" } }}error.code: código interno estable (no depende del idioma ni del texto).error.message: texto legible para humanos (puede ser localizado).error.status: redundante pero útil para clientes y logs.error.traceId: correlación para soporte y debugging (idealmente también en header).error.details: lista de problemas (por campo o por regla).error.links.about: URL a documentación del error (sin palabras en la imagen, pero aquí sí aplica).
Headers complementarios
X-Request-IdoTraceparent(W3C): correlación distribuida.Content-Type: application/problem+json(opcional): si adoptas RFC 7807, mantén consistencia y mapea tus campos cuidadosamente.
Diferenciar errores de cliente vs servidor
La regla operativa: si el cliente puede corregir la solicitud, es un error de cliente; si el cliente no puede hacer nada razonable, es servidor o infraestructura. Aunque el status HTTP ya lo sugiere, tu error.code debe reforzarlo.
| Categoría | Ejemplos | Señales | Acción del cliente |
|---|---|---|---|
| Cliente (4xx) | Validación, autenticación, autorización, conflicto, rate limit | Solicitud inválida o no permitida | Corregir datos, credenciales, permisos o reintentar más tarde |
| Servidor (5xx) | Errores no controlados, dependencias caídas | Fallo interno o upstream | Reintentar con backoff; reportar con traceId |
Validaciones coherentes: 400 vs 422 y conflictos 409
Cuándo usar 400 (Bad Request)
Úsalo cuando la solicitud es sintácticamente inválida o no puede parsearse/interpretarse: JSON mal formado, tipos imposibles, parámetros incompatibles a nivel de formato.
HTTP/1.1 400 Bad Request{ "error": { "code": "REQ_MALFORMED_JSON", "message": "El cuerpo JSON no es válido.", "status": 400, "traceId": "..." }}Cuándo usar 422 (Unprocessable Entity)
Úsalo cuando la solicitud es sintácticamente correcta, pero falla reglas de negocio o validaciones semánticas: campos requeridos, rangos, formatos, combinaciones inválidas.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
HTTP/1.1 422 Unprocessable Entity{ "error": { "code": "REQ_VALIDATION_FAILED", "message": "Hay errores de validación.", "status": 422, "traceId": "...", "details": [ { "field": "password", "issue": "minLength", "message": "Mínimo 12 caracteres." }, { "field": "birthDate", "issue": "range", "message": "Debe ser una fecha pasada." } ], "links": { "about": "https://api.ejemplo.com/docs/errors#REQ_VALIDATION_FAILED" } }}Cuándo usar 409 (Conflict)
Úsalo cuando la solicitud es válida, pero entra en conflicto con el estado actual del recurso o una restricción de unicidad/consistencia: email ya registrado, versión desactualizada, transición de estado no permitida.
HTTP/1.1 409 Conflict{ "error": { "code": "USR_EMAIL_ALREADY_EXISTS", "message": "Ya existe un usuario con ese email.", "status": 409, "traceId": "...", "details": [ { "field": "email", "issue": "unique", "message": "El email ya está en uso." } ] }}Cómo representar múltiples errores
Evita responder “uno por uno” obligando al cliente a iterar. Devuelve una lista en error.details con todos los problemas detectados en una pasada.
Convenciones útiles para details
field: ruta del campo (por ejemploaddress.streetoitems[3].sku).issue: clave estable de la regla (required,format,min,max,enum).message: texto legible (localizable).value: opcional; cuidado con PII (ver sección de seguridad).location: opcional para distinguirbody,query,path,header.
{ "error": { "code": "REQ_VALIDATION_FAILED", "message": "Hay errores de validación.", "status": 422, "traceId": "...", "details": [ { "location": "query", "field": "limit", "issue": "max", "message": "Máximo 100." }, { "location": "body", "field": "items[0].quantity", "issue": "min", "message": "Debe ser al menos 1." } ] }}Errores de autenticación y autorización
401 Unauthorized (no autenticado o token inválido)
Úsalo cuando faltan credenciales o son inválidas/expiradas. Incluye WWW-Authenticate cuando aplique (por ejemplo, Bearer).
HTTP/1.1 401 UnauthorizedWWW-Authenticate: Bearer error="invalid_token"{ "error": { "code": "AUTH_INVALID_TOKEN", "message": "La sesión no es válida o expiró.", "status": 401, "traceId": "...", "links": { "about": "https://api.ejemplo.com/docs/errors#AUTH_INVALID_TOKEN" } }}403 Forbidden (autenticado pero sin permisos)
Úsalo cuando el usuario está autenticado pero no tiene autorización. Evita revelar si el recurso existe cuando eso sea sensible; en algunos casos se prefiere responder 404 para no enumerar recursos.
HTTP/1.1 403 Forbidden{ "error": { "code": "AUTH_FORBIDDEN", "message": "No tienes permisos para realizar esta acción.", "status": 403, "traceId": "..." }}Rate limiting: 429 Too Many Requests
Cuando el cliente excede límites, responde 429 e incluye headers estándar para guiar reintentos. Mantén el cuerpo con tu formato de error.
Retry-After: segundos o fecha HTTP.X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset(si usas este esquema).
HTTP/1.1 429 Too Many RequestsRetry-After: 30X-RateLimit-Limit: 60X-RateLimit-Remaining: 0X-RateLimit-Reset: 1700000000{ "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Has excedido el límite de solicitudes. Intenta más tarde.", "status": 429, "traceId": "...", "details": [ { "issue": "retryAfter", "message": "Reintenta en 30 segundos." } ], "links": { "about": "https://api.ejemplo.com/docs/errors#RATE_LIMIT_EXCEEDED" } }}Guía práctica paso a paso para implementar consistencia
Paso 1: Define un catálogo de errores con códigos estables
Crea un catálogo versionado (en repositorio) con códigos, status, descripción, y si es “exponible” al cliente. Evita códigos generados dinámicamente.
| Código | Status | Uso | Exponible |
|---|---|---|---|
| REQ_MALFORMED_JSON | 400 | JSON inválido | Sí |
| REQ_VALIDATION_FAILED | 422 | Validación semántica | Sí |
| USR_EMAIL_ALREADY_EXISTS | 409 | Unicidad | Sí |
| AUTH_INVALID_TOKEN | 401 | Token inválido | Sí |
| AUTH_FORBIDDEN | 403 | Sin permisos | Sí |
| RATE_LIMIT_EXCEEDED | 429 | Límite excedido | Sí |
| INTERNAL_ERROR | 500 | Fallo no controlado | Sí (genérico) |
Convención recomendada: prefijos por dominio o capa (REQ_, AUTH_, USR_, PAY_, etc.). Mantén los códigos estables aunque cambie el texto.
Paso 2: Centraliza el mapeo de excepciones a respuestas
Implementa un manejador global (middleware/filtro) que convierta errores internos a tu formato estándar. Esto evita que cada controlador “invente” su respuesta.
- Errores de parseo →
REQ_MALFORMED_JSON(400). - Errores de validación →
REQ_VALIDATION_FAILED(422) condetails. - Violación de unicidad/estado → códigos de conflicto (409).
- Errores no previstos →
INTERNAL_ERROR(500) con mensaje genérico.
Paso 3: Diseña validaciones por capas (y coherentes)
- Validación de entrada (forma): tipos, requeridos, longitudes, formatos.
- Validación de negocio (reglas): invariantes, transiciones de estado, límites por plan.
- Validación de consistencia (concurrencia): versiones, conflictos, recursos ya existentes.
Regla práctica: si el error se puede atribuir a un campo o regla concreta, debe aparecer en details.
Paso 4: Estandariza traceId y correlación
Genera un traceId por request (o adopta el de tu gateway) y devuélvelo en:
- Header:
X-Request-Id: ... - Cuerpo:
error.traceId
En logs, registra siempre traceId, error.code, status, endpoint y actor (si aplica).
Paso 5: Documenta enlaces por error
En la documentación, cada error.code debe tener: significado, causas comunes, cómo resolver, ejemplos de respuesta y campos en details. El link links.about debe ser estable.
Pautas para no filtrar información sensible
Qué no devolver al cliente
- Stack traces, nombres de tablas/columnas, consultas SQL, rutas de archivos, configuración interna.
- Mensajes de excepciones de dependencias (DB, colas, servicios internos) sin sanitizar.
- Datos personales en
details.value(tokens, contraseñas, documentos, emails completos si no es necesario).
Patrones seguros
- Mensaje externo genérico, detalle interno en logs: el cliente recibe
INTERNAL_ERROR, el servidor registra la excepción completa contraceId. - Minimización de datos: en validación, reporta el campo y la regla; evita eco de valores sensibles. Ejemplo: para
password, nunca devuelvasvalue. - Evitar enumeración: en autenticación/recuperación de cuenta, no confirmes si un usuario existe (mismo mensaje para “email no existe” y “email existe”).
- Consistencia en 404/403: cuando la existencia del recurso sea sensible, considera responder 404 en lugar de 403.
Ejemplos integrales por escenario
Parámetro de consulta inválido (400)
GET /users?limit=abcHTTP/1.1 400 Bad Request{ "error": { "code": "REQ_INVALID_QUERY_PARAM", "message": "Parámetro de consulta inválido.", "status": 400, "traceId": "...", "details": [ { "location": "query", "field": "limit", "issue": "type", "message": "Debe ser un número." } ] }}Validación de payload (422) con múltiples errores
POST /users{ "email": "foo@", "password": "123" }HTTP/1.1 422 Unprocessable Entity{ "error": { "code": "REQ_VALIDATION_FAILED", "message": "Hay errores de validación.", "status": 422, "traceId": "...", "details": [ { "field": "email", "issue": "format", "message": "Debe ser un email válido." }, { "field": "password", "issue": "minLength", "message": "Mínimo 12 caracteres." } ] }}Conflicto por unicidad (409)
POST /users{ "email": "ana@ejemplo.com", "password": "..." }HTTP/1.1 409 Conflict{ "error": { "code": "USR_EMAIL_ALREADY_EXISTS", "message": "Ya existe un usuario con ese email.", "status": 409, "traceId": "...", "details": [ { "field": "email", "issue": "unique", "message": "El email ya está en uso." } ] }}Acceso sin token (401) vs sin permisos (403)
GET /admin/reportsHTTP/1.1 401 UnauthorizedWWW-Authenticate: Bearer{ "error": { "code": "AUTH_MISSING_TOKEN", "message": "Faltan credenciales de autenticación.", "status": 401, "traceId": "..." }}GET /admin/reportsHTTP/1.1 403 Forbidden{ "error": { "code": "AUTH_FORBIDDEN", "message": "No tienes permisos para realizar esta acción.", "status": 403, "traceId": "..." }}Rate limit (429) con reintento guiado
HTTP/1.1 429 Too Many RequestsRetry-After: 30{ "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Has excedido el límite de solicitudes. Intenta más tarde.", "status": 429, "traceId": "..." }}