Plantilla de Respuesta de Error de API
Una plantilla reutilizable para respuestas de error de API consistentes, informativas y amigables para desarrolladores que reducen el tiempo de depuración.
Resumen
Un desarrollador que se integra con tu API recibe un 500. Lo que vuelve es <html><head><title>Server Error</title></head>... — la página por defecto de Nginx. No hay request ID que citar en un ticket de soporte, ni pista de si envió algo mal o tu servicio se rompió, ni documentación que leer. Abre un ticket, espera un día, y descubre que la causa era un rate limit que podrías haber comunicado en un solo campo JSON.
Las respuestas de error son la parte de tu contrato de API que los desarrolladores leen bajo estrés, normalmente a las 2 de la madrugada, normalmente con algo en llamas. Una buena les dice exactamente qué falló, por qué, qué hacer después, y les da un ID que citar cuando escalen. Una mala convierte una corrección de dos minutos en un hilo de soporte de varios días — o te hace perder una integración sin que nadie te avise.
Esta plantilla cubre:
- RFC 7807 / RFC 9457 Problem Details — el formato estándar para errores de APIs HTTP
- Formato simple legacy — para APIs internas o compatibilidad hacia atrás
- Catálogo de códigos de error — mapeo de códigos HTTP a tipos de error específicos
- Implementación del lado del servidor — middleware que produce problem+json sin filtrar detalles internos
- Buenas prácticas — qué incluir, qué ocultar y cómo enlazar a la documentación
Estructura de la Plantilla
Usa esta plantilla para construir respuestas de error consistentes y útiles para cualquier API REST o HTTP. El formato combina de forma natural con la Plantilla de Documentación API, que cubre cómo documentar estos casos de error por endpoint.
RFC 7807 Problem Details (Recomendado)
La RFC 7807 estandarizó application/problem+json para que los clientes HTTP — y las librerías que usan — puedan parsear errores automáticamente en lugar de buscar coincidencias en tus mensajes. Fue reemplazada por la RFC 9457 en 2023, que aclara el mismo formato. La gente sigue diciendo “errores RFC 7807” por costumbre.
{
"type": "https://api.example.com/errors/invalid-request",
"title": "Solicitud Inválida",
"status": 400,
"detail": "El campo 'email' debe ser una dirección de correo válida.",
"instance": "/orders/123e4567-e89b-12d3-a456-426614174000",
"errors": [
{
"field": "email",
"message": "debe ser una dirección de correo válida",
"code": "invalid_format"
}
]
}
Referencia de Campos
| Campo | Requerido | Descripción |
|---|---|---|
type | Sí | URI que identifica el tipo de error y enlaza a documentación legible |
title | Sí | Resumen breve y legible del problema — estable por type; no interpolar detalles aquí |
status | Sí | Código de estado HTTP (debe coincidir con la respuesta real) |
detail | No | Explicación específica de esta ocurrencia; aquí van los detalles concretos |
instance | No | Referencia URI que identifica esta ocurrencia concreta del problema |
errors | No | Miembro de extensión: arreglo de errores a nivel de campo para 400 Bad Request |
Dos detalles que los equipos se equivocan con frecuencia. Primero, status debe ser igual al estado HTTP en el cable — una respuesta 200 que lleva "status": 400 en el cuerpo confunde a cada cliente e intermediario. Segundo, title debe ser idéntico para cada ocurrencia de un type; hay clientes que ramifican sobre él. La información específica de la ocurrencia va en detail.
Formato Simple Legacy
Para APIs internas o compatibilidad hacia atrás, una estructura ligera es aceptable:
{
"error": {
"code": "INVALID_PARAMETER",
"message": "El parámetro 'page_size' debe estar entre 1 y 100.",
"request_id": "req_abc123xyz"
}
}
Si publicas este formato, mantén los nombres de campo estables para siempre — los clientes escriben parsers contra ellos, y renombrar message a detail en una release “minor” es un breaking change disfrazado.
Ejemplos Completos por Código de Estado
400 Bad Request — Error de Validación
{
"type": "https://api.example.com/errors/validation-failed",
"title": "Validación Fallida",
"status": 400,
"detail": "3 campos fallaron validación.",
"instance": "/users",
"errors": [
{
"field": "email",
"message": "debe ser una dirección de correo válida",
"code": "invalid_format"
},
{
"field": "password",
"message": "debe tener al menos 12 caracteres",
"code": "min_length"
},
{
"field": "role",
"message": "debe ser uno de: admin, editor, viewer",
"code": "invalid_enum"
}
]
}
Devuelve todos los errores de campo en una sola respuesta, no solo el primero. Un cliente que corrige errores de uno en uno a través de cinco idas y vueltas odiará tu API; un cliente que recibe la lista completa los arregla en una pasada.
401 Unauthorized
{
"type": "https://api.example.com/errors/unauthorized",
"title": "No Autorizado",
"status": 401,
"detail": "Header Authorization faltante o inválido. Usa autenticación Bearer token.",
"instance": "/orders/123"
}
401 significa “autentícate (o reautentícate) y reintenta”. Acompáñalo de un header WWW-Authenticate que le diga al cliente qué esquema esperas.
403 Forbidden
{
"type": "https://api.example.com/errors/forbidden",
"title": "Prohibido",
"status": 403,
"detail": "Tu plan solo permite acceso de lectura. Contacta al administrador de tu workspace para solicitar acceso de escritura.",
"instance": "/reports"
}
Autenticado pero sin permiso — y a diferencia del 401, reintentar no ayuda. El detail debe decir quién puede conceder el acceso o qué acción lo desbloquea.
404 Not Found
{
"type": "https://api.example.com/errors/resource-not-found",
"title": "Recurso No Encontrado",
"status": 404,
"detail": "La orden con ID '123e4567-e89b-12d3-a456-426614174000' no existe.",
"instance": "/orders/123e4567-e89b-12d3-a456-426614174000"
}
Una decisión sutil: si quien llama no tiene permiso para saber que el recurso existe, devolver 404 (y no 403) evita filtrar su existencia. Aplícalo con consistencia, o los clientes aprenderán a distinguir tus dos comportamientos de todas formas.
409 Conflict
{
"type": "https://api.example.com/errors/conflict",
"title": "Conflicto",
"status": 409,
"detail": "Ya existe un usuario con el email 'a@b.com'.",
"instance": "/users"
}
Úsalo para conflictos de estado: recursos duplicados, fallos de optimistic locking, operaciones ya procesadas. Para creaciones idempotentes, considera devolver el recurso existente con un 200/303 en su lugar.
422 Unprocessable Content
{
"type": "https://api.example.com/errors/unprocessable",
"title": "Contenido No Procesable",
"status": 422,
"detail": "No se puede programar una entrega para una fecha pasada: '2020-01-01'.",
"instance": "/shipments"
}
La petición era JSON sintácticamente válido pero semánticamente incorrecta. Dónde termina el 400 y empieza el 422 es genuinamente difuso. Elige una convención, documéntala en tu catálogo, y nunca uses ambas para la misma condición.
429 Rate Limit Exceeded
{
"type": "https://api.example.com/errors/rate-limit-exceeded",
"title": "Límite de Peticiones Excedido",
"status": 429,
"detail": "Has excedido el límite de 200 peticiones por minuto. Reintenta en 30 segundos.",
"instance": "/reports"
}
Los headers de respuesta para 429 deberían incluir:
Retry-After: 30
X-RateLimit-Limit: 200
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1721003460
500 Internal Server Error
{
"type": "https://api.example.com/errors/internal",
"title": "Error Interno del Servidor",
"status": 500,
"detail": "Ocurrió un error inesperado. Referencia el request ID req_abc123 al contactar soporte.",
"instance": "/orders"
}
Esta es la respuesta donde la sanitización más importa: el cliente recibe un request ID y un mensaje genérico, mientras el stack trace, la consulta SQL y los hostnames internos van a tus logs, nunca al cuerpo. El OWASP API Security Top 10 lista los mensajes de error verbosos como vector de fuga de información exactamente por esta razón.
Catálogo de Códigos de Error (Ejemplo)
Mantén el catálogo legible por máquina para que la documentación, el código y los tests se generen desde una sola fuente — no tres copias que divergen.
| Estado HTTP | Código de Error | Cuándo Usar |
|---|---|---|
| 400 | INVALID_REQUEST | Solicitud malformada genérica |
| 400 | VALIDATION_ERROR | Falló validación de esquema o regla de negocio |
| 401 | UNAUTHORIZED | Token de autenticación faltante o inválido |
| 403 | FORBIDDEN | Usuario autenticado sin permisos |
| 404 | RESOURCE_NOT_FOUND | El recurso solicitado no existe |
| 409 | CONFLICT | Recurso ya existe o conflicto de estado |
| 422 | UNPROCESSABLE_ENTITY | Falló validación semántica |
| 429 | RATE_LIMIT_EXCEEDED | Demasiadas peticiones |
| 500 | INTERNAL_ERROR | Fallo inesperado del servidor |
| 503 | SERVICE_UNAVAILABLE | Interrupción temporal o mantenimiento |
Guía de nombres: usa UPPER_SNAKE_CASE, sé específico (TOKEN_EXPIRED, no BAD_TOKEN), y nunca recicles un código retirado para un significado distinto. Los clientes antiguos siguen interpretándolo a la manera vieja.
Implementación del Lado del Servidor
El formato solo sobrevive si es el camino de menor resistencia — si los handlers tienen que optar por él, lo olvidarán. Centralízalo en middleware para que cada handler, incluidos los que nadie recuerda, emita la misma forma.
Express (Node.js)
// error-handler.js — montarlo AL FINAL, después de todas las rutas
class ApiError extends Error {
constructor(status, type, title, detail, errors) {
super(detail || title);
this.status = status;
this.type = type;
this.title = title;
this.errors = errors;
}
}
function problemDetails(err, req, res, next) {
const isApiError = err instanceof ApiError;
const status = isApiError ? err.status : 500;
// El contexto completo va a los logs, no al cliente
if (status >= 500) {
console.error({ requestId: req.id, err });
}
res.status(status).type('application/problem+json').json({
type: isApiError ? err.type : 'https://api.example.com/errors/internal',
title: isApiError ? err.title : 'Internal Server Error',
status,
detail: status >= 500
? `An unexpected error occurred. Reference request ID ${req.id} when contacting support.`
: err.message,
instance: req.originalUrl,
...(err.errors && { errors: err.errors }),
request_id: req.id,
});
}
// Uso: throw new ApiError(422, '.../errors/unprocessable', 'Unprocessable Content',
// "Cannot schedule delivery for a past date.");
Flask (Python)
from flask import jsonify, request, g
@app.errorhandler(Exception)
def problem_details(err):
status = getattr(err, "status", 500)
if status >= 500:
app.logger.error("unhandled", extra={"request_id": g.request_id}, exc_info=err)
detail = f"An unexpected error occurred. Reference request ID {g.request_id} when contacting support."
else:
detail = getattr(err, "detail", str(err))
body = {
"type": getattr(err, "type", "https://api.example.com/errors/internal"),
"title": getattr(err, "title", "Internal Server Error" if status >= 500 else "Error"),
"status": status,
"detail": detail,
"instance": request.path,
"request_id": g.request_id,
}
return jsonify(body), status, {"Content-Type": "application/problem+json"}
Los dos principios en ambos ejemplos: el request_id enlaza la captura de pantalla del cliente con tus logs, y la rama 500 nunca confía en err.message. Solo los errores esperados (tu clase ApiError) se ganan el derecho de llegar al cuerpo de la respuesta. Un helper que genera estos handlers desde tu catálogo está en los recursos companion.
Lo que funciona
- Siempre devuelve un cuerpo: nunca envíes un cuerpo vacío para respuestas
4xxo5xx - Usa Problem Details para APIs públicas: los consumidores esperan formatos estándar; las librerías pueden parsearlos automáticamente
- Incluye un request ID: esencial para correlacionar reportes de clientes con logs del servidor
- No filtres stack traces: los detalles internos pertenecen a los logs, no a las respuestas
- Localiza
detailsi es necesario: usaAccept-Languagepara mensajes localizados, pero manténtitleestable - Enlaza a documentación: el URI
typedebe llevar a una página de docs que explique el error y cómo corregirlo - Sé específico en errores de validación: di
"el teléfono debe coincidir con formato E.164"en vez de"entrada inválida" - Incluye guía de reintento para 429: el header
Retry-Afterindica a los clientes cuándo retroceder - Usa códigos de error consistentes: los clientes construyen lógica de retry sobre códigos específicos, no sobre estados HTTP
Errores Comunes
- Devolver
500con una página HTML plana — rompe clientes de API que esperan JSON (normalmente una página de error por defecto del servidor o del gateway que intercepta antes que tu handler) - Incluir stack traces o consultas SQL en el cuerpo del error: riesgo de seguridad y fuga de información
- Usar
"error": "algo salió mal"genérico para cada fallo: imposible de depurar - Cambiar nombres de campos de error entre versiones: rompe la lógica de parsing del cliente
- No loguear el contexto completo del error en el servidor: pierdes la capacidad de investigar lo que el cliente solo vio como
request_id - Devolver
200 OKcon un cuerpo de error: viola la semántica HTTP y rompe middleware, reintentos y cachés - Formatos de error distintos por endpoint: los clientes necesitan un formato consistente
- Interpolar dentro de
title:titledebe permanecer estable portype; los detalles de la ocurrencia van endetail - Tratar 401 y 403 como intercambiables: 401 invita a reautenticarse, 403 no
Comparación de Variantes
| Variante | Contexto | Enfoque | Notas |
|---|---|---|---|
| RFC 7807/9457 Problem Details | API pública, clientes externos | JSON estructurado con type, title, status | Estándar IETF, librerías disponibles |
| Formato simple legacy | API interna, compatibilidad | code + message + request_id | Mínimo, suficiente para APIs pequeñas |
| Errores GraphQL | API GraphQL | Arreglo errors con extensions | No usa códigos de estado HTTP |
| Errores gRPC | Microservicios internos | Códigos de estado 0-16 + detalles protobuf | Binario, alto rendimiento |
Escenario Detallado: Manejar una Cascada de Errores
Sistema: una API de pedidos que llama a un servicio de inventario. Flujo: POST /orders → verificar stock → crear pedido. El servicio de inventario no responde, el circuit breaker se abre, y el cliente necesita saber qué acaba de pasar.
- Detección del error. La petición a inventario agota el timeout tras 3s y el circuit breaker se abre tras 3 fallos consecutivos. El error es del downstream, no del cliente: un 503, no un 400.
- Respuesta al cliente.
503 Service UnavailableconContent-Type: application/problem+json,Retry-After: 60, y un cuerpo que llevarequest_idycorrelation_idpara que el cliente pueda citarlos en un ticket. - Logging interno. La petición completa, el timeout y el estado del breaker van a los logs etiquetados
downstream_timeout, service=inventory, másinventory_timeout_count++y un trace span (inventory.call, status=error, duración=3001ms). - Comunicación al cliente. Un 503 con
Retry-Aftersignifica fallo temporal: el cliente reintenta con backoff exponencial y solo muestra un mensaje de mantenimiento si los 503 persisten. - Degradación (opcional). Con el breaker abierto, acepta pedidos en modo degradado marcados como
pending_stock_verificationy reconcilia cuando el inventario se recupere.
Variantes
Errores GraphQL
GraphQL devuelve errores en el arreglo errors de la respuesta en lugar de códigos de estado HTTP de error. Cada error incluye message, locations, path y opcionalmente extensions:
{
"errors": [
{
"message": "El campo 'email' debe ser una dirección de correo válida.",
"locations": [{ "line": 3, "column": 10 }],
"path": ["createUser", "input", "email"],
"extensions": { "code": "INVALID_FORMAT" }
}
]
}
Pon los códigos de tu catálogo en extensions.code para que los clientes REST y GraphQL compartan el mismo vocabulario.
Errores gRPC
gRPC reemplaza los códigos de estado HTTP con su propio rango (0-16). Mapea los códigos de estado de gRPC a tu catálogo e incluye details con información de error estructurada usando mensajes protobuf — INVALID_ARGUMENT mapea a tu VALIDATION_ERROR, UNAVAILABLE a SERVICE_UNAVAILABLE, y así sucesivamente.
Errores async/webhooks
Para webhooks, devuelve errores como payloads de eventos: el ID del evento original, un código de error y un timestamp. Provee un endpoint de webhook de reintento para que los clientes puedan reenviar entregas fallidas.
Troubleshooting
- Los clientes ven HTML en vez de problem+json. Un proxy inverso o la página de error por defecto del framework está interceptando el error. Revisa la configuración de error-pages del gateway (
error_pageen nginx, el orden del middleware de excepciones) — tu handler solo corre para los errores que puede ver. - El
statusdel cuerpo no coincide con el estado HTTP. Algo reescribió la respuesta después de tu handler (un catch-all, una transformación del gateway, o unres.json()llamado dos veces). Verifica en tests que el estado en el cable es igual alstatusdel cuerpo. - Los clientes no pueden parsear el error. Verifica
Content-Type: application/problem+json— muchos clientes ramifican sobre él, y usarapplication/jsonles enseña a adivinar. - El mismo problema devuelve códigos distintos en endpoints distintos. El catálogo no se aplica de forma centralizada — un handler devuelve
VALIDATION_ERRORy otro devuelveINVALID_REQUESTpara la misma condición. Genera los handlers desde el catálogo, o bloquea merges con un contract test. - Los cuerpos de error llegan vacíos bajo carga. Los timeouts y los atajos del circuit breaker se saltan la ruta normal del handler — verifica que el middleware del breaker también produce problem+json, no solo un 503 pelado.
Ver también
- Plantilla de Documentación API — documenta los casos de error de cada endpoint junto a este formato
- Plantilla de Aviso de Deprecación de API — comunica breaking changes antes de que rompan a los clientes
- Guía de Diseño de APIs REST — el contexto de diseño más amplio en el que encaja este formato de error
- Patrón Circuit Breaker — el patrón de resiliencia detrás del escenario de cascada
- RFC 9457 — Problem Details for HTTP APIs — el estándar actual (reemplaza a la RFC 7807)
- MDN — Códigos de estado de respuesta HTTP — referencia de semántica de códigos de estado
- OWASP API Security Top 10 — por qué los errores verbosos son un vector de fuga de información
Código companion: recursos de respuestas de error de API — middleware problem-details.js y problem_details.py más el catálogo de errores como error-responses.json.
Preguntas frecuentes
¿Debería usar RFC 7807 para APIs internas?
Sí, incluso para APIs internas. La pequeña sobrecarga de agregar type y title se paga cuando otro equipo necesita integrarse, y te obliga a documentar los casos de error. La RFC 9457 reemplazó a la 7807 en 2023; el formato es el mismo, así que usa el número que cite tu equipo.
¿Qué pasa si un error tiene múltiples causas?
Usa el arreglo errors con un objeto por causa. Cada objeto debe incluir field, message y opcionalmente code. Esa es la forma habitual de las fallas de validación, donde más de un campo puede estar mal. Devuélvelos todos a la vez en lugar de hacer que el cliente corrija errores de uno en uno.
¿Cómo manejo errores de servicios downstream?
Envuelve los errores downstream en tu propio formato. Considera los patrones Circuit Breaker y Retry para comunicación downstream resiliente. No hagas proxy de cuerpos de error de terceros directamente. Mapea el fallo downstream a uno de tus códigos de error documentados, loguea la respuesta upstream original y devuelve un mensaje sanitizado al cliente.
¿Debería incluir un correlation ID además del request ID?
Sí. Un request ID identifica la petición del cliente; un correlation ID traza la petición a través de los servicios. Incluye ambos: request_id para el cliente y correlation_id para el tracing interno. Así es como depuras un sistema distribuido donde una petición del cliente se abanica en una cadena de llamadas a servicios.
¿Cómo versiono los formatos de respuesta de error?
Agrega campos nuevos, nunca elimines ni renombres existentes. Si necesitas breaking changes, crea un nuevo formato de error y usa content negotiation (Accept: application/problem+json; version=2) para que los clientes opten. Documenta la estrategia de versionado en tu Documentación de API.
¿Cómo documento errores que varían según el plan del cliente?
Usa el mismo código de error pero varía el campo detail según el contexto. Un plan gratuito que excede el límite de exports devuelve 403 con detail: "Tu plan permite 100 exports/mes. Actualiza para más.". No crees códigos de error separados por plan — el código identifica el problema, no el contexto del usuario.
¿Debería incluir timestamps en las respuestas de error?
Sí — agrega un campo timestamp en formato ISO 8601. Los clientes pueden correlacionar errores con sus propios logs y presentar reportes precisos. Algunos equipos prefieren el header Date de HTTP, pero un campo en el cuerpo es más útil para clientes que solo persisten el cuerpo del error.
¿Debería devolver errores distintos para el mismo problema según el contexto?
No. La misma condición de error debería producir el mismo código y estado de error independientemente del endpoint que lo disparó. La información específica del contexto va en detail e instance, no en el código de error.
Recursos Relacionados
Plantilla de Documentación de API
Una plantilla reutilizable para documentar APIs REST y GraphQL con endpoints, schemas, errores y ejemplos.
DocPlantilla de Aviso de Deprecación de API
Plantilla para comunicar deprecaciones de API, cambios breaking y plazos de retiro a los consumidores.
GuideGuía de Diseño de APIs REST
Una Referencia Detallada para diseñar APIs REST limpias, escalables y mantenibles.
RecipeDiseño de APIs REST: Lo que funciona
Diseña APIs REST robustas y escalables con métodos HTTP adecuados, códigos de estado, versionado y estrategias de paginación.