Guía de Manejo de Errores de API
Guía para estandarizar respuestas de error, códigos de estado y payloads de error en APIs REST y GraphQL.
Resumen
Las respuestas de error inconsistentes confunden a los consumidores de API y aumentan el tiempo de integración. Un endpoint retorna texto plano, otro retorna JSON anidado, y un tercero retorna HTML. Esta guía estandariza como tus APIs comunican fallas para que los consumidores puedan manejar errores programaticamente sin adivinar formatos. Documentos relacionados: Cómo documentar una API con OpenAPI, Swagger UI y Redoc y Implementar logging y audit trails de API.
Cuando Usar
Usa este recurso cuando:
- Diseñas una API nueva o versiones una existente
- Incorpores un nuevo equipo que construira endpoints de API
- Revises una API para consistencia antes del lanzamiento público
- Los consumidores reporten que el manejo de errores es difícil o impredecible
Si la pregunta es arreglar errores en REST o migrar a GraphQL, consulta primero GraphQL vs REST — Cuándo elegir y cómo migrar.
Solución
# Estándar de Manejo de Errores de API
## 1. Códigos de Estado HTTP
| Estado | Significado | Cuando Usar |
|--------|-------------|-------------|
| 400 | Solicitud Incorrecta | Errores de validación, JSON malformado, campos requeridos faltantes |
| 401 | No Autorizado | Credenciales de autenticación faltantes o inválidas |
| 403 | Prohibido | Autenticado pero no autorizado para este recurso |
| 404 | No Encontrado | El recurso no existe |
| 409 | Conflicto | El recurso ya existe, entrada duplicada, conflicto de estado |
| 422 | Entidad No Procesable | Semánticamente correcto pero violacion de regla de negocio |
| 429 | Demasiadas Solicitudes | Limite de tasa excedido |
| 500 | Error Interno del Servidor | Falla inesperada del servidor (evitar exponer detalles) |
| 503 | Servicio No Disponible | Interrupción temporal, header Retry-After proporcionado |
## 2. Formato de Respuesta de Error (REST)
```json
{
"error": {
"id": "err_7f8a9b2c",
"code": "INVALID_PARAMETER",
"message": "El campo 'email' debe ser una dirección de correo válida.",
"details": [
{
"field": "email",
"issue": "invalid_format",
"value": "no-es-email"
}
],
"timestamp": "2026-06-26T10:00:00Z",
"path": "/v1/users",
"retryable": false,
"documentation_url": "https://docs.example.com/errors/INVALID_PARAMETER"
}
}
3. Formato de Respuesta de Error (GraphQL)
{
"data": null,
"errors": [
{
"message": "El campo 'email' debe ser una dirección de correo válida.",
"extensions": {
"code": "INVALID_PARAMETER",
"errorId": "err_7f8a9b2c",
"field": "email",
"retryable": false,
"documentationUrl": "https://docs.example.com/errors/INVALID_PARAMETER"
}
}
]
}
4. Registro de Códigos de Error
| Código | Estado HTTP | Descripción |
|---|---|---|
| INVALID_PARAMETER | 400 | Fallo de validación de campo |
| MISSING_REQUIRED_FIELD | 400 | Campo requerido no proporcionado |
| AUTHENTICATION_FAILED | 401 | Credenciales inválidas o expiradas |
| PERMISSION_DENIED | 403 | Permisos insuficientes |
| RESOURCE_NOT_FOUND | 404 | Recurso solicitado no encontrado |
| DUPLICATE_RESOURCE | 409 | El recurso ya existe |
| RATE_LIMIT_EXCEEDED | 429 | Demasiadas solicitudes |
| INTERNAL_ERROR | 500 | Error inesperado del servidor |
5. Comportamiento de Reintento
| Código de Error | Estrategia de Reintento | Reintentos Máximos | Backoff |
|---|---|---|---|
| RATE_LIMIT_EXCEEDED | Esperar header Retry-After | 3 | Exponencial |
| INTERNAL_ERROR | Reintentar con jitter | 3 | Exponencial 1s, 2s, 4s |
| SERVICE_UNAVAILABLE | Reintentar con jitter | 5 | Exponencial |
| INVALID_PARAMETER | No reintentar | 0 | N/A |
| AUTHENTICATION_FAILED | No reintentar | 0 | N/A |
6. Requisitos de Logging
Cada respuesta de error debe ser logueada con:
- ID de Error (para correlación)
- ID de Solicitud (de la solicitud entrante)
- ID de Usuario (si esta autenticado)
- Ruta del endpoint y método
- Payload completo de error
- Stack trace (solo errores 500, logs internos)
- Timestamp
## Explicación
La guía obliga a que cada error incluya un código legible por máquina (`INVALID_PARAMETER`) y un mensaje legible por humanos. El `errorId` permite a los consumidores referenciar la falla exacta al contactar soporte. El booleano `retryable` indica a los SDKs si el reintento automático es seguro. Separar los formatos REST y GraphQL reconoce que GraphQL retorna 200 OK incluso para errores, por lo que la información de error vive en el arreglo `errors`.
## Implementación de Manejador de Errores
### Manejador Central de Errores en Express.js
```javascript
function errorHandler(err, req, res, next) {
const errorId = `err_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const statusCode = err.statusCode || 500;
const code = err.code || "INTERNAL_ERROR";
const payload = {
error: {
id: errorId,
code: code,
message: statusCode >= 500
? "Ocurrio un error interno. Por favor reintente."
: err.message,
details: err.details || [],
timestamp: new Date().toISOString(),
path: req.path,
retryable: err.retryable || false,
documentation_url: `https://docs.example.com/errors/${code}`,
},
};
if (statusCode >= 500) {
logger.error({
errorId,
code,
message: err.message,
stack: err.stack,
requestId: req.id,
userId: req.user?.id,
path: req.path,
method: req.method,
});
} else {
logger.warn({
errorId,
code,
message: err.message,
requestId: req.id,
path: req.path,
});
}
res.status(statusCode).json(payload);
}
class ApiError extends Error {
constructor(code, message, statusCode, options = {}) {
super(message);
this.code = code;
this.statusCode = statusCode;
this.details = options.details || [];
this.retryable = options.retryable || false;
}
}
// Uso en manejadores de rutas
app.post("/v1/users", (req, res, next) => {
if (!req.body.email || !req.body.email.includes("@")) {
return next(new ApiError("INVALID_PARAMETER", "El campo 'email' debe ser un email válido.", 400, {
details: [{ field: "email", issue: "invalid_format", value: req.body.email }],
}));
}
res.status(201).json({ id: 1, email: req.body.email });
});
app.use(errorHandler);
Manejador de Errores en Python Flask
import uuid
import logging
from datetime import datetime, timezone
from flask import Flask, request, jsonify, g
logger = logging.getLogger(__name__)
class ApiError(Exception):
def __init__(self, code, message, status_code, details=None, retryable=False):
self.code = code
self.message = message
self.status_code = status_code
self.details = details or []
self.retryable = retryable
super().__init__(message)
@app.errorhandler(ApiError)
def handle_api_error(e):
error_id = f"err_{uuid.uuid4().hex[:12]}"
payload = {
"error": {
"id": error_id,
"code": e.code,
"message": e.message,
"details": e.details,
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"path": request.path,
"retryable": e.retryable,
"documentation_url": f"https://docs.example.com/errors/{e.code}",
}
}
if e.status_code >= 500:
logger.error(
"Error de API",
extra={
"errorId": error_id,
"code": e.code,
"requestId": getattr(g, "request_id", None),
"userId": getattr(g, "user_id", None),
},
)
return jsonify(payload), e.status_code
Logging Estructurado para Errores
Usa logging estructurado en JSON para que los errores sean buscables y correlacionables entre servicios:
{
"timestamp": "2026-06-26T10:00:00.123Z",
"level": "error",
"errorId": "err_7f8a9b2c",
"requestId": "req_abc123",
"userId": "usr_xyz789",
"code": "INTERNAL_ERROR",
"message": "Timeout de conexion a base de datos",
"stack": "Error: Timeout de conexion a base de datos\n at ...",
"path": "/v1/orders",
"method": "POST",
"duration_ms": 5023,
"service": "order-service",
"versión": "2.1.0"
}
Formato RFC 9457 Problem Details
Para APIs REST que quieren seguir un estándar IETF, usa RFC 9457 (anteriormente RFC 7807):
{
"type": "https://docs.example.com/errors/INVALID_PARAMETER",
"title": "Parámetro Invalido",
"status": 400,
"detail": "El campo 'email' debe ser un email válido.",
"instance": "/v1/users",
"traceId": "err_7f8a9b2c",
"errors": [
{
"field": "email",
"issue": "invalid_format",
"value": "no-es-un-email"
}
]
}
Establece el header Content-Type a application/problem+json para respuestas RFC 9457.
Cómo adoptar este estándar
Meter una norma así en una API existente funciona mejor por etapas que en una sola reescritura:
- Publica primero el registro. Congela los códigos de error de la sección 4 antes de tocar código — cada cambio posterior es una migración, así que la lista tiene que quedar acordada pronto.
- Despliega el manejador central. Conecta el handler de Express o Flask de abajo (o el equivalente en tu framework) y pasa cada error nuevo por él. Los endpoints existentes migran poco a poco; los nuevos nunca lo saltan.
- Convierte por tráfico, no por página. Empieza por tus 5 endpoints con más volumen de errores — cubren a la mayoría de consumidores. La cola larga puede esperar a la siguiente ventana de mantenimiento.
- Mantén el sobre viejo una versión. Si los consumidores ya parsean tus errores, emite el doble formato o versiona el cambio como cualquier ruptura — las ediciones silenciosas del esquema rompen a los clientes más que los bugs.
Mantener el registro de errores
El registro de la sección 4 solo sirve mientras siga siendo la única fuente de verdad:
- Un archivo (o una tabla) es dueño de los códigos; el código los referencia por constante, nunca por string literal.
- Los códigos nuevos pasan por revisión como los cambios de API — cualquiera propone, un solo dueño aprueba.
- Los códigos son solo-adición. Retíralos con un flag
deprecated: truey un punteroreplaced_by; nunca los borres. - La página pública de referencia de códigos se genera desde el registro, no se escribe a mano.
Errores de gateway vs de aplicación
Dos capas producen errores y conviene que sigan distinguiéndose:
| Capa | Ejemplos | Forma |
|---|---|---|
| Gateway / proxy | 401 sin token, 429 por límite, 502/503 upstream | Mismo sobre, pero con campo source: "gateway" |
| Aplicación | validación, reglas de negocio, no encontrado | Sobre completo con details |
Los consumidores reintentan los 5xx de gateway pero no los de aplicación — etiquetar el origen en el payload les ahorra adivinarlo. Mantén los mensajes de gateway escuetos; la aplicación es la dueña de los explicativos.
Consumir errores en el cliente
Un error tipado en el cliente convierte el sobre en comportamiento en lugar de comparación de strings:
async function callApi(path) {
const res = await fetch(path);
if (res.ok) return res.json();
const { error } = await res.json().catch(() => ({}));
const apiError = new ApiClientError(
error?.code ?? 'UNKNOWN',
error?.message ?? `HTTP ${res.status}`,
{ id: error?.id, retryable: error?.retryable ?? res.status >= 500 },
);
if (apiError.retryable) throw apiError;
if (apiError.code === 'RATE_LIMIT_EXCEEDED') {
const wait = Number(res.headers.get('Retry-After') ?? 5) * 1000;
await new Promise(r => setTimeout(r, wait));
return callApi(path);
}
throw apiError;
}
El patrón que importa: bifurca por code, no por message — los mensajes son para humanos y pueden cambiar en cualquier release.
Probar el contrato de errores
El sobre es parte del contrato, así que pruébalo como el camino feliz:
- Guarda un snapshot de una respuesta
4xxy una5xxpor endpoint en tu suite — un renombrado de campo rompe clientes igual que un bug. - Asegura que
error.codese mantiene estable mientrasmessagepuede evolucionar. - Los contract tests del camino negativo van junto a los positivos — consulta Pruebas de Contratos de API con Consumer-Driven Contracts para el setup con Pact.
- Reproduce un
429real en staging: los clientes que no respetanRetry-Afterse enteran el primer día, no durante un incidente.
Límites de peticiones, Retry-After y los 429
El rate limiting merece su propio bloque en el estándar porque bajo “demasiado” conviven dos errores distintos:
- 429 Too Many Requests — el cliente está tocando el límite ahora. Envía siempre
Retry-After(segundos o fecha HTTP) para que los clientes honestos retrocedan solos. - Cuota agotada — el plan se terminó, esperar no ayuda. Usa
402o403con códigoQUOTA_EXCEEDEDy unadocumentation_urlapuntando a las opciones de upgrade.
Acompaña el 429 con los mismos headers X-RateLimit-* que ya envías en las respuestas exitosas, e incluye el timestamp de reset dentro de details para que el cliente no tenga que mirar en dos sitios.
Documentar errores por endpoint
El registro cubre los códigos compartidos, pero cada endpoint debería listar sus propios fallos en su documentación:
| Endpoint | Códigos que puede devolver | Por qué |
|---|---|---|
POST /v1/users | INVALID_PARAMETER, DUPLICATE_RESOURCE | validación + email único |
GET /v1/users/{id} | RESOURCE_NOT_FOUND | el id no existe |
POST /v1/orders | INVALID_PARAMETER, RATE_LIMIT_EXCEEDED, INSUFFICIENT_FUNDS | regla de negocio sobre los compartidos |
Si un endpoint puede devolver un código que no está en la tabla, el consumidor lo trata como un bug en tu documentación. Genera la tabla desde el registro siempre que puedas — las tablas escritas a mano quedan viejas en cuanto sale un código nuevo.
Elegir un código de estado
Cuando un fallo nuevo no encaja en un status obvio, recorre este orden antes de inventar nada:
- ¿Es culpa del cliente? → 4xx. El más específico: 400 malformado → 422 regla de negocio → 404 no existe → 409 conflicto de estado → 429 demasiados.
- ¿Es culpa tuya? → 5xx. 500 inesperado, 503 caída temporal (siempre con
Retry-After). - ¿Está a medias? — p. ej., falló un proveedor upstream →
502 Bad Gateway, no 500; el consumidor trata los fallos de terceros de otra forma (suelen ser reintentables).
Dos trampas: nunca uses 200-con-flag-de-error (rompe a todo cliente HTTP), y no uses 404 para esconder recursos prohibidos salvo que el estándar lo decida deliberadamente — elige una política y déjala escrita.
Errores en jobs asíncronos y webhooks
Los fallos que ocurren después de devolver el 202 no pueden viajar en la respuesta HTTP — necesitan el mismo sobre por otro canal:
- Webhook callback: cuando el job termina con error, haz POST del payload de error a la URL de callback del consumidor con exactamente la misma forma de objeto
error—id,code,message,retryable. El consumidor reutiliza un solo parser para ambos caminos. - Consultando el endpoint del job:
GET /v1/jobs/{id}devuelvestatus: "failed"más el mismo objetoerrorembebido bajo una claveresult.error. - Sin canal: loguéalo con el formato estructurado y notifica al dueño del job internamente — los fallos silenciosos de jobs son los bugs más difíciles de rastrear.
Use el canal que use, el registro de códigos es el mismo — un job que falla validación usa INVALID_PARAMETER, no un JOB_FAILED genérico. Los códigos cajón de sastre enseñan al consumidor a rendirse con el manejo tipado.
Logging vs respuesta: qué va en cada lado
El payload de respuesta y la entrada de log son dos vistas del mismo evento con audiencias distintas:
| Campo | Respuesta | Log interno |
|---|---|---|
errorId | sí — el consumidor lo cita en tickets | sí — clave de correlación |
code / message | sí | sí |
details | sí (sanitizados) | sí (completos) |
| Stack trace | nunca | solo 5xx |
| IDs de request/usuario | solo request ID | todos |
| SQL, internals, valores de entorno | nunca | sí, si ayudan a depurar |
La regla de reparto cabe en una línea: todo lo que el consumidor pueda necesitar legítimamente va en la respuesta; todo lo que ayuda a tu equipo va en el log. Lo que ayude a un atacante no va en ningún sitio.
Deprecar códigos de error
Los códigos mueren como los endpoints — despacio y por escrito. Cuando un código se retira:
- Márcalo en el registro:
"deprecated": true,"sunset": "2026-12-01","replaced_by": "NUEVO_CODIGO". - Sigue aceptándolo (y emitiéndolo) hasta la fecha de retirada — los consumidores construyeron integraciones sobre el contrato viejo.
- Anúncialo en el changelog y, si emites headers
Deprecation/Sunset, ponlos en las respuestas que aún llevan el código viejo. - El día de la retirada, quítalo de la referencia pública pero conserva la entrada del registro como
removed— los logs antiguos siguen necesitando una clave.
Nunca reasignes un código retirado a un significado distinto. Un concepto nuevo recibe código nuevo; reutilizar el viejo convierte los logs históricos y los docs cacheados en mentira.
Variantes
| Contexto | Enfoque | Notas |
|---|---|---|
| API Pública | Formato completo con URLs de docs | Los consumidores necesitan debugging autoservicio |
| API Interna | Formato ligero | Payload menor, consumidores mas simples |
| Microservicios | Incluir request ID para tracing | Esencial para debugging distribuido |
| gRPC | Usar códigos de status con proto de detalles | gRPC tiene su propio modelo de errores |
Lo que funciona
- Nunca retornar stack traces o SQL en respuestas de error — loguearlos internamente
- Usar un registro de errores centralizado para prevenir que los equipos inventen nuevos códigos
- Siempre incluir un request ID para tracing entre servicios
- Documentar cada código de error en una página de referencia pública
- Retornar 404 para recursos faltantes sin distinguir “existe pero esta prohibido”
- Usar una clase de error personalizada en tu codebase para estandarizar la creación de errores
- Loguear 4xx a nivel warn, 5xx a nivel error — diferente severidad para diferentes audiencias
Errores Comunes
- Retornar 200 OK para errores — rompe la deteccion de errores del cliente
- Nombres de campo inconsistentes (
error_messagevsmessagevsdetail) - No distinguir errores reintentables vs no reintentables
- Exponer detalles de implementación interna en mensajes de error
- Usar 500 para errores de validación — 500 significa bug del servidor, no error del usuario
- Formatos de error diferentes por endpoint — cada equipo inventa su propia estructura
- No loguear errores 4xx — los errores de cliente revelan patrones de mal uso de la API
- Retornar el ID de error solo en logs — los consumidores lo necesitan en la respuesta para referenciarlo
Lectura Adicional
- RFC 9457 — Problem Details for HTTP APIs — el estándar de formato de errores
- OWASP — Seguridad API — las respuestas de error son un vector de vulnerabilidad
Preguntas frecuentes
¿Las APIs GraphQL deberían devolver HTTP 200 para todos los requests?
200 para éxito a nivel de transporte — los errores de aplicación viajan en el array errors. 400 funciona para sintaxis GraphQL mal formada; 401/403 para fallas de autenticación.
¿Cómo manejo errores de validación con múltiples campos?
Devuelve un array details — una entrada por campo fallido, cada una con el nombre del campo, qué salió mal, y el valor inválido.
¿Qué pasa si un consumidor envía una versión de API inválida?
400 Bad Request con código UNSUPPORTED_API_VERSION, y lista las versiones soportadas en el mensaje. Nunca 404 — el endpoint existe; es la versión la que no.
¿Debería usar RFC 9457 Problem Details o un formato propio?
RFC 9457 te compra interoperabilidad — application/problem+json es algo que algunas librerías de cliente ya saben parsear. Un formato propio te compra espacio para crecer — campos extra como retryable o documentation_url. ¿No necesitas el espacio? Quédate con el estándar.
¿Cómo manejo errores en jobs async/de fondo?
Loguéalo con el mismo formato estructurado y notifica al dueño del job. Si el job tiene un webhook callback, empuja un evento de error a la URL del callback con la misma estructura de payload.
¿Debería localizar los mensajes de error?
Devuelve siempre códigos legibles por máquina. Para el campo message legible por humanos, usa el header Accept-Language del consumidor para devolver texto localizado. Mantén el código en inglés para que sea buscable sin importar el locale.
¿Cómo versiono los códigos de error?
Mantén los códigos de error estables entre versiones de API. ¿Necesitas un significado distinto? Acuña un código nuevo — no redefinas el viejo. Depreca los códigos retirados con una fecha de sunset y apunta al reemplazo en la referencia de errores.
¿Qué status HTTP uso para rate limiting vs cuota excedida?
429 Too Many Requests para rate limiting (temporal, reintentar tras espera). 402 Payment Required o 403 Forbidden con código QUOTA_EXCEEDED para cuota agotada (requiere upgrade de plan, no solo esperar).
Recursos Relacionados
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.
DocPlantilla de Revisión de Seguridad de API (OWASP)
Una plantilla de checklist rellenable para revisar autenticación, autorización, rate limiting y cobertura del OWASP API Security Top 10.
RecipeManejar Errores en APIs con RFC 7807
Patrones para un manejo de errores de API consistente y predecible en varios lenguajes y frameworks.
GuideGuía Completa de Estrategias de Versionado de APIs
Compara versionado por URI, header, query-param y negociación de contenido para REST, más evolución de esquema GraphQL y migración.
GuideGuía de Diseño de APIs REST
Una Referencia Detallada para diseñar APIs REST limpias, escalables y mantenibles.
GuideGraphQL vs REST — Cuándo Elegir y Cómo Migrar
Guía de decisión comparando GraphQL y REST APIs: casos de uso, rendimiento, caching, herramientas y estrategias de migración para equipos de ingeniería.