StackPractices
beginner Por Mathias Paulenko

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ódigoEstado HTTPDescripción
INVALID_PARAMETER400Fallo de validación de campo
MISSING_REQUIRED_FIELD400Campo requerido no proporcionado
AUTHENTICATION_FAILED401Credenciales inválidas o expiradas
PERMISSION_DENIED403Permisos insuficientes
RESOURCE_NOT_FOUND404Recurso solicitado no encontrado
DUPLICATE_RESOURCE409El recurso ya existe
RATE_LIMIT_EXCEEDED429Demasiadas solicitudes
INTERNAL_ERROR500Error inesperado del servidor

5. Comportamiento de Reintento

Código de ErrorEstrategia de ReintentoReintentos MáximosBackoff
RATE_LIMIT_EXCEEDEDEsperar header Retry-After3Exponencial
INTERNAL_ERRORReintentar con jitter3Exponencial 1s, 2s, 4s
SERVICE_UNAVAILABLEReintentar con jitter5Exponencial
INVALID_PARAMETERNo reintentar0N/A
AUTHENTICATION_FAILEDNo reintentar0N/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:

  1. 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.
  2. 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.
  3. 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.
  4. 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: true y un puntero replaced_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:

CapaEjemplosForma
Gateway / proxy401 sin token, 429 por límite, 502/503 upstreamMismo sobre, pero con campo source: "gateway"
Aplicaciónvalidación, reglas de negocio, no encontradoSobre 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 4xx y una 5xx por endpoint en tu suite — un renombrado de campo rompe clientes igual que un bug.
  • Asegura que error.code se mantiene estable mientras message puede 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 429 real en staging: los clientes que no respetan Retry-After se 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 402 o 403 con código QUOTA_EXCEEDED y una documentation_url apuntando 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:

EndpointCódigos que puede devolverPor qué
POST /v1/usersINVALID_PARAMETER, DUPLICATE_RESOURCEvalidación + email único
GET /v1/users/{id}RESOURCE_NOT_FOUNDel id no existe
POST /v1/ordersINVALID_PARAMETER, RATE_LIMIT_EXCEEDED, INSUFFICIENT_FUNDSregla 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:

  1. ¿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.
  2. ¿Es culpa tuya? → 5xx. 500 inesperado, 503 caída temporal (siempre con Retry-After).
  3. ¿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} devuelve status: "failed" más el mismo objeto error embebido bajo una clave result.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:

CampoRespuestaLog interno
errorIdsí — el consumidor lo cita en ticketssí — clave de correlación
code / messagesísí
detailssí (sanitizados)sí (completos)
Stack tracenuncasolo 5xx
IDs de request/usuariosolo request IDtodos
SQL, internals, valores de entornonuncasí, 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:

  1. Márcalo en el registro: "deprecated": true, "sunset": "2026-12-01", "replaced_by": "NUEVO_CODIGO".
  2. Sigue aceptándolo (y emitiéndolo) hasta la fecha de retirada — los consumidores construyeron integraciones sobre el contrato viejo.
  3. Anúncialo en el changelog y, si emites headers Deprecation/Sunset, ponlos en las respuestas que aún llevan el código viejo.
  4. 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

ContextoEnfoqueNotas
API PúblicaFormato completo con URLs de docsLos consumidores necesitan debugging autoservicio
API InternaFormato ligeroPayload menor, consumidores mas simples
MicroserviciosIncluir request ID para tracingEsencial para debugging distribuido
gRPCUsar códigos de status con proto de detallesgRPC tiene su propio modelo de errores

Lo que funciona

  1. Nunca retornar stack traces o SQL en respuestas de error — loguearlos internamente
  2. Usar un registro de errores centralizado para prevenir que los equipos inventen nuevos códigos
  3. Siempre incluir un request ID para tracing entre servicios
  4. Documentar cada código de error en una página de referencia pública
  5. Retornar 404 para recursos faltantes sin distinguir “existe pero esta prohibido”
  6. Usar una clase de error personalizada en tu codebase para estandarizar la creación de errores
  7. Loguear 4xx a nivel warn, 5xx a nivel error — diferente severidad para diferentes audiencias

Errores Comunes

  1. Retornar 200 OK para errores — rompe la deteccion de errores del cliente
  2. Nombres de campo inconsistentes (error_message vs message vs detail)
  3. No distinguir errores reintentables vs no reintentables
  4. Exponer detalles de implementación interna en mensajes de error
  5. Usar 500 para errores de validación — 500 significa bug del servidor, no error del usuario
  6. Formatos de error diferentes por endpoint — cada equipo inventa su propia estructura
  7. No loguear errores 4xx — los errores de cliente revelan patrones de mal uso de la API
  8. Retornar el ID de error solo en logs — los consumidores lo necesitan en la respuesta para referenciarlo
Mermaid flowchart LR diagram

Lectura Adicional

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).