En la lección anterior fuimos dejando números por el camino: 201 al crear con Location, 204 sin cuerpo, 404 frente a 410, 409 cuando la transición no es posible, 415 cuando el Content-Type no se admite. Cada uno de esos códigos es una decisión de contrato: es lo primero que lee un cliente y lo que determina si reintenta, si muestra un error al usuario, si redirige o si cierra la sesión. Elegir mal el código convierte en indescifrable una API que por lo demás está bien diseñada. Esta lección recorre los códigos que una API REST usa de verdad —no la lista completa del registro de IANA—, construye un árbol de decisión para acertar siempre, y diseña el cuerpo del error de Tienda Aroma, comparándolo con el estándar application/problem+json.

Contenido

  1. Las cinco familias y por qué importan
  2. La familia 2xx: éxito
  3. La familia 3xx: redirección
  4. La familia 4xx: error del cliente
  5. La familia 5xx: error del servidor
  6. Tabla maestra de Tienda Aroma
  7. Árbol de decisión: cómo elegir el código correcto
  8. El cuerpo del error: problem+json y el formato de Tienda Aroma
  9. Catálogo de códigos de error de negocio
  10. Antipatrones

  1. Las cinco familias y por qué importan

En 01-03 vimos el mapa; ahora entramos en el detalle. La primera cifra del código clasifica la respuesta:

Familia Significado ¿De quién es el problema? ¿Debe reintentar el cliente?
1xx Informativa De nadie, es protocolo —
2xx Éxito — No
3xx Redirección Del cliente, que debe seguir otra ruta Sí, a otra URI
4xx Error del cliente Del cliente No, sin cambiar la petición
5xx Error del servidor Del servidor Sí, con reintentos espaciados

Esta clasificación no es decorativa: es lógica de la que depende software real. Un cliente HTTP genérico, un balanceador o el gateway de un socio deciden basándose solo en la primera cifra. Si devuelves 200 para un error, ningún reintento automático se disparará y ninguna alerta saltará. Si devuelves 500 por un dato mal escrito por el cliente, el sistema reintentará una petición que nunca va a funcionar, y tu equipo de guardia recibirá un aviso a las tres de la mañana por un fallo que no es suyo.

De la familia 1xx solo merece mención 100 Continue, que gestionan los clientes HTTP de forma transparente al subir cuerpos grandes. No la usarás explícitamente.

  1. La familia 2xx: éxito

200 OK

El éxito genérico, con cuerpo. Lo usan GET, PUT, PATCH correctos y los POST de acción que no crean recurso nuevo.

HTTP/1.1 200 OK
Content-Type: application/json

{ "id": "caf_001", "nombre": "Etiopía Yirgacheffe", "precioEuros": 14.50 }

Recuerda de 02-03: un GET de colección sin resultados es 200 con {"datos": [], "total": 0}, nunca 404.

201 Created

Se ha creado un recurso. Obligatoria la cabecera Location con su URI.

HTTP/1.1 201 Created
Content-Type: application/json
Location: https://api.tiendaaroma.example/v1/pedidos/ped_5001

{
  "id": "ped_5001",
  "clienteId": "cli_842",
  "estado": "pendiente_pago",
  "totalEuros": 29.00,
  "fechaCreacion": "2026-03-14T10:30:00Z",
  "_links": {
    "self": { "href": "/v1/pedidos/ped_5001" },
    "pagar": { "href": "/v1/pedidos/ped_5001/pago", "method": "POST" }
  }
}

Location es lo que permite al cliente encadenar sin construir URLs a mano, y es el mínimo de hipermedia que toda API debería cumplir. En Tienda Aroma la devuelven POST /pedidos, POST /cafes, POST /cafes/{id}/resenas, POST /pedidos/{id}/pago y todos los subrecursos de acción.

202 Accepted

"He aceptado la petición, pero todavía no la he procesado." Es el código del procesamiento asíncrono, y su contrato incluye decirle al cliente dónde consultar el progreso.

HTTP/1.1 202 Accepted
Content-Type: application/json
Location: https://api.tiendaaroma.example/v1/pedidos/ped_5001/devolucion

{
  "estado": "en_revision",
  "mensaje": "Tu solicitud de devolución se revisará en un plazo de 24 horas.",
  "_links": { "estado": { "href": "/v1/pedidos/ped_5001/devolucion" } }
}

Cuidado con la trampa del 202: cuando devuelves 202 estás diciendo que puede fallar después, así que necesitas un recurso donde el cliente vea el resultado final. Un 202 sin sitio donde mirar es un agujero negro.

204 No Content

Éxito sin cuerpo. El cliente no debe intentar parsear nada. Usos en Tienda Aroma: DELETE correcto, PUT/PATCH cuando el cliente no necesita la representación, y OPTIONS.

HTTP/1.1 204 No Content

Regla estricta: 204 significa cuerpo de longitud cero. Devolver 204 con un JSON dentro rompe clientes que, correctamente, ni leen el flujo.

206 Partial Content

Respuesta parcial a una petición con Range. En Tienda Aroma aparece exactamente en un sitio: la descarga reanudable de la factura en PDF.

GET /v1/pedidos/ped_5001/factura HTTP/1.1
Accept: application/pdf
Range: bytes=24000-48212
HTTP/1.1 206 Partial Content
Content-Type: application/pdf
Content-Range: bytes 24000-48212/48213
Content-Length: 24213

No se usa para paginar JSON: para eso están los mecanismos de 02-06.

  1. La familia 3xx: redirección

301 Moved Permanently

El recurso ha cambiado de URI para siempre. Tienda Aroma lo usa para normalizar la barra final (/cafes/ → /cafes) y para las URIs heredadas de la API antigua.

HTTP/1.1 301 Moved Permanently
Location: https://api.tiendaaroma.example/v1/cafes

304 Not Modified

"Lo que tienes en caché sigue valiendo." Responde a peticiones condicionales con If-None-Match o If-Modified-Since, y va sin cuerpo, que es justamente el ahorro.

GET /v1/cafes/caf_001 HTTP/1.1
If-None-Match: "a1b2c3d4"
HTTP/1.1 304 Not Modified
ETag: "a1b2c3d4"
Cache-Control: public, max-age=300

La caché condicional completa —ETags, Last-Modified, validación, revalidación— se desarrolla en 04-06. Aquí basta con saber que 304 no es un error, sino un éxito muy barato.

307 y 308 frente a 302

El problema histórico: ante un 301 o 302, muchos clientes convertían un POST en un GET al seguir la redirección, cosa que el estándar no pretendía pero que se consolidó como práctica. Para eliminar la ambigüedad se crearon dos códigos que garantizan que el método y el cuerpo se conservan:

Código Permanencia ¿Conserva método y cuerpo? Uso recomendado
301 Permanente En la práctica, no (POST → GET) Recursos movidos, solo con GET
302 Found Temporal Ambiguo Evitar en APIs
307 Temporary Redirect Temporal Sí Mantenimiento, redirección a otra región
308 Permanent Redirect Permanente Sí Cambio definitivo de URI conservando el método

Decisión de Tienda Aroma: no se usa 302 en ningún caso. Para lo permanente, 301 si solo afecta a lecturas y 308 si puede afectar a escrituras; para lo temporal, 307.

  1. La familia 4xx: error del cliente

Es la familia más rica y la que más se equivoca. En todas ellas el cuerpo lleva el formato de error de la sección 8.

400 Bad Request

La petición está mal formada o los datos no son válidos: JSON con error de sintaxis, tipo incorrecto, campo obligatorio ausente, campo desconocido (recuerda: Tienda Aroma es estricta en la entrada), parámetro de query inválido.

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": {
    "codigo": "datos_invalidos",
    "mensaje": "El cuerpo de la petición contiene errores de validación.",
    "detalles": [
      { "campo": "precioEuros", "problema": "Debe ser un número mayor que 0.", "valorRecibido": -3 },
      { "campo": "tueste", "problema": "Valor no permitido. Valores válidos: claro, medio, oscuro.", "valorRecibido": "tostadísimo" }
    ]
  }
}

Nota de diseño importante: se devuelven todos los errores de validación a la vez, no el primero. Un formulario que falla campo a campo, en peticiones sucesivas, es una tortura para el usuario.

401 Unauthorized frente a 403 Forbidden

La distinción que más se equivoca del mundo HTTP. La forma de recordarla:

  • 401 = "no sé quién eres". Falta el Authorization, el token es inválido o ha caducado. La respuesta debe incluir WWW-Authenticate. El cliente puede arreglarlo autenticándose.
  • 403 = "sé quién eres y no puedes". La identidad es válida, pero le faltan permisos. Volver a autenticarse no sirve de nada.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api.tiendaaroma.example", error="invalid_token"
Content-Type: application/json

{ "error": { "codigo": "token_caducado", "mensaje": "El token de acceso ha caducado.", "detalles": [] } }
HTTP/1.1 403 Forbidden
Content-Type: application/json

{ "error": { "codigo": "permisos_insuficientes", "mensaje": "Se requiere el rol 'moderador' para aprobar reseñas.", "detalles": [] } }

El nombre 401 Unauthorized es un error histórico del estándar: debería llamarse Unauthenticated. Consecuencia práctica en la SPA de Tienda Aroma: ante 401 intenta refrescar el token y reintentar una vez; ante 403 muestra directamente "no tienes permiso" y no reintenta.

Existe un tercer caso, incómodo pero importante: cuando un cliente autenticado pide un recurso ajeno (GET /v1/pedidos/ped_9999, que es de otra persona), responder 403 confirma que ese pedido existe. Para evitar esa fuga, Tienda Aroma responde 404 en los recursos privados de otros clientes. Es una decisión de seguridad deliberada, se llama enmascaramiento y se trata en 04-02.

404 Not Found frente a 410 Gone

404 Not Found 410 Gone
Significado No hay nada aquí (quizá nunca lo hubo, quizá no lo puedes ver) Existió y se ha eliminado definitivamente
¿Puede volver? Puede que sí No
Qué hace un rastreador Reintenta más adelante Elimina la URL de su índice
Uso en Tienda Aroma Id inexistente, recurso ajeno Café descatalogado, versión de API apagada

410 es más informativo cuando lo sabes con certeza: le dice al cliente que deje de pedirlo. Es especialmente útil en el apagado de versiones antiguas (02-07).

405 Method Not Allowed

El recurso existe, pero no admite ese método. Obligatoria la cabecera Allow.

HTTP/1.1 405 Method Not Allowed
Allow: GET, POST, HEAD, OPTIONS
Content-Type: application/json

{ "error": { "codigo": "metodo_no_permitido", "mensaje": "DELETE no está permitido sobre /v1/cafes.", "detalles": [] } }

Distínguelo de 404: si DELETE /v1/cafes diera 404, el desarrollador buscaría una errata en la URL en vez de darse cuenta de que el método es el equivocado.

406 Not Acceptable y 415 Unsupported Media Type

Se confunden porque las dos hablan de formatos, pero apuntan en direcciones opuestas:

  • 406 → el servidor no puede producir lo que el cliente pide en Accept (salida).
  • 415 → el servidor no entiende lo que el cliente envía en Content-Type (entrada).
GET /v1/cafes/caf_001 HTTP/1.1
Accept: application/xml
HTTP/1.1 406 Not Acceptable
Content-Type: application/json

{ "error": { "codigo": "formato_no_disponible", "mensaje": "Solo se admite application/json.", "detalles": [] } }
PATCH /v1/cafes/caf_001 HTTP/1.1
Content-Type: application/json-patch+json
HTTP/1.1 415 Unsupported Media Type
Accept-Patch: application/merge-patch+json
Content-Type: application/json

{ "error": { "codigo": "formato_no_soportado", "mensaje": "Este recurso solo admite application/merge-patch+json.", "detalles": [] } }

409 Conflict

La petición es válida pero choca con el estado actual del recurso. Es el código de la lógica de negocio, y en Tienda Aroma aparece a menudo:

POST /v1/pedidos HTTP/1.1
Content-Type: application/json
Idempotency-Key: 5f3b9c2a-1d7e-4a44-9f30-8b1c2d3e4f50

{ "clienteId": "cli_842", "lineas": [{ "cafeId": "caf_002", "cantidad": 100 }] }
HTTP/1.1 409 Conflict
Content-Type: application/json

{
  "error": {
    "codigo": "stock_insuficiente",
    "mensaje": "No hay unidades suficientes de 'Colombia Huila'.",
    "detalles": [
      { "cafeId": "caf_002", "solicitado": 100, "disponible": 80 }
    ]
  }
}

Otros conflictos del catálogo: pedido_ya_pagado, pedido_ya_anulado, resena_ya_moderada, operacion_en_curso. La regla para distinguirlo de 400: si los datos son correctos y lo que impide la operación es el estado del recurso, es 409.

412 Precondition Failed

Falló una condición previa enviada por el cliente, típicamente If-Match con un ETag antiguo. Es el mecanismo de concurrencia optimista que evita la actualización perdida:

PATCH /v1/cafes/caf_001 HTTP/1.1
If-Match: "a1b2c3d4"
Content-Type: application/merge-patch+json

{ "stock": 95 }
HTTP/1.1 412 Precondition Failed
Content-Type: application/json

{ "error": { "codigo": "conflicto_version", "mensaje": "El recurso ha cambiado desde tu última lectura.", "detalles": [] } }

Los ETags y las peticiones condicionales se desarrollan en 04-06.

422 Unprocessable Content y el debate 400 vs 422

422 (renombrado Unprocessable Content en la RFC 9110) significa: la sintaxis es correcta, entiendo el documento, pero sus contenidos no se pueden procesar. Ejemplo canónico: un JSON perfectamente formado que pide una fecha de entrega en el pasado.

El debate lleva años abierto y estas son las dos posturas:

Postura Regla A favor En contra
Solo 400 Todo error del cliente es 400; el detalle va en el cuerpo Simple, no hay que decidir; 422 viene de WebDAV Se pierde una distinción útil para clientes automáticos
400 + 422 400 para errores de sintaxis/formato; 422 para errores semánticos Distingue "no te entiendo" de "te entiendo y no puedo" Fronteras difusas: ¿un enumerado inválido es sintaxis o semántica?

Decisión de Tienda Aroma: 400 para todos los errores de validación de entrada (sintaxis, tipos, campos obligatorios, enumerados, rangos), con el detalle campo a campo en detalles. 422 se reserva para un caso muy concreto y bien delimitado: la reutilización indebida de una Idempotency-Key con un cuerpo distinto (02-03), donde la petición es impecable pero no se puede procesar por un motivo que no es ni de formato ni de estado del recurso.

Lo importante no es cuál de las dos posturas eliges, sino escribirla en la guía de estilo y no desviarte: lo que rompe a los clientes es que el mismo tipo de fallo devuelva 400 en un endpoint y 422 en otro.

429 Too Many Requests

Se ha superado el límite de peticiones. Debe acompañarse de Retry-After y, en Tienda Aroma, de las cabeceras de cuota:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
Aroma-RateLimit-Limite: 1000
Aroma-RateLimit-Restantes: 0
Content-Type: application/json

{ "error": { "codigo": "limite_peticiones", "mensaje": "Has superado el límite de 1000 peticiones por hora.", "detalles": [] } }

Las políticas, ventanas y algoritmos de limitación son la lección 04-04.

  1. La familia 5xx: error del servidor

Aquí el cliente no ha hecho nada mal. Regla de oro: nunca filtres detalles internos —trazas de pila, consultas SQL, rutas de fichero, nombres de servidores— porque son información de oro para un atacante.

500 Internal Server Error

El cajón de sastre: excepción no controlada. Debe llevar un identificador de traza para que el cliente pueda citarlo al abrir una incidencia:

HTTP/1.1 500 Internal Server Error
Content-Type: application/json

{
  "error": {
    "codigo": "error_interno",
    "mensaje": "Se ha producido un error inesperado. Contacta con soporte citando el identificador de traza.",
    "detalles": [],
    "trazaId": "trz_8f4a1c92"
  }
}

Ese trazaId es la costura entre la respuesta y tus logs, y es la base de la observabilidad (04-07).

502, 503 y 504

Código Significado Causa típica en Tienda Aroma ¿Reintentar?
502 Bad Gateway Respuesta inválida de un servicio aguas arriba La pasarela de pago devuelve basura Sí, con espera
503 Service Unavailable Servicio no disponible temporalmente Mantenimiento, saturación, arranque Sí, según Retry-After
504 Gateway Timeout Un servicio aguas arriba no respondió a tiempo El servicio gRPC de stock supera su plazo Sí, con cuidado

503 es el único que puede planificarse, y por eso lleva Retry-After, que admite segundos o una fecha HTTP:

HTTP/1.1 503 Service Unavailable
Retry-After: 120
Content-Type: application/json

{ "error": { "codigo": "servicio_no_disponible", "mensaje": "Mantenimiento programado. Vuelve a intentarlo en 2 minutos.", "detalles": [] } }

Precaución con 504: el tiempo de espera se agotó, pero la operación puede haberse ejecutado igualmente aguas arriba. Es exactamente el escenario que justifica las claves de idempotencia de 02-03.

  1. Tabla maestra de Tienda Aroma

Código Cuándo se usa Ejemplo concreto
200 Lectura o actualización correctas GET /v1/cafes/caf_001
201 Recurso creado (+ Location) POST /v1/pedidos
202 Aceptado para procesar después POST /v1/pedidos/ped_5001/devolucion
204 Éxito sin cuerpo DELETE /v1/carritos/car_77/lineas/caf_002
206 Descarga parcial con Range PDF de /v1/pedidos/ped_5001/factura
301 URI movida permanentemente /v1/cafes/ → /v1/cafes
304 La caché del cliente sigue vigente GET /v1/cafes/caf_001 con If-None-Match
307 Redirección temporal conservando método Desvío durante mantenimiento
308 Redirección permanente conservando método Reubicación de un endpoint de escritura
400 Petición o datos inválidos precioEuros: -3
401 Falta autenticación o token caducado Sin cabecera Authorization
403 Autenticado pero sin permisos Cliente intentando aprobar una reseña
404 No existe (o no puedes verlo) GET /v1/cafes/caf_999
405 Método no permitido (+ Allow) DELETE /v1/cafes
406 No se puede servir el Accept pedido Accept: application/xml
409 Conflicto con el estado actual stock_insuficiente, pedido_ya_pagado
410 Existió y se eliminó para siempre Café descatalogado; API /v0 apagada
412 Falló If-Match (ETag antiguo) Dos ediciones simultáneas de stock
413 Cuerpo demasiado grande Imagen de café de 20 MB
415 Content-Type no soportado PATCH con application/json-patch+json
422 Correcto pero no procesable Idempotency-Key reutilizada con otro cuerpo
429 Límite de peticiones superado 1001 peticiones en una hora
500 Error inesperado del servidor Excepción no controlada
502 Servicio aguas arriba responde mal Pasarela de pago caída
503 No disponible temporalmente (+ Retry-After) Mantenimiento programado
504 Timeout aguas arriba Servicio de stock lento

  1. Árbol de decisión: cómo elegir el código correcto

graph TD
    A{"¿Se ha procesado<br/>correctamente?"} -->|No| B{"¿De quién es<br/>el fallo?"}
    A -->|"Sí, pero aún no<br/>está terminado"| ACC["202 Accepted"]
    A -->|Sí| C{"¿Se ha creado<br/>un recurso?"}
    C -->|Sí| CRE["201 Created<br/>+ Location"]
    C -->|No| D{"¿Hay cuerpo<br/>que devolver?"}
    D -->|Sí| OK["200 OK"]
    D -->|No| NC["204 No Content"]
    B -->|"Del servidor"| E{"¿Es temporal?"}
    E -->|Sí| SRV["503 + Retry-After<br/>502 / 504 si es aguas arriba"]
    E -->|No| ERR["500 + trazaId"]
    B -->|"Del cliente"| F{"¿Sabemos<br/>quién es?"}
    F -->|"No autenticado"| U401["401 + WWW-Authenticate"]
    F -->|"Sin permisos"| U403["403 Forbidden"]
    F -->|Sí| G{"¿Existe el<br/>recurso?"}
    G -->|"No, y no volverá"| G410["410 Gone"]
    G -->|No| G404["404 Not Found"]
    G -->|Sí| H{"¿El método<br/>está permitido?"}
    H -->|No| H405["405 + Allow"]
    H -->|Sí| I{"¿Son válidos<br/>los datos?"}
    I -->|No| I400["400 datos_invalidos"]
    I -->|Sí| J{"¿El estado del recurso<br/>permite la operación?"}
    J -->|No| J409["409 Conflict"]
    J -->|Sí| OK

Recorre este árbol para cada endpoint nuevo y tendrás la mitad de la documentación escrita.

  1. El cuerpo del error: problem+json y el formato de Tienda Aroma

El código de estado dice qué categoría de fallo ha ocurrido; el cuerpo dice qué ha pasado exactamente. Sin cuerpo, un 400 obliga al desarrollador a adivinar.

8.1. El estándar: application/problem+json (RFC 9457)

Existe un formato estándar de cuerpo de error, definido originalmente en la RFC 7807 y actualizado por la RFC 9457:

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://api.tiendaaroma.example/errores/stock-insuficiente",
  "title": "Stock insuficiente",
  "status": 409,
  "detail": "Solo quedan 80 unidades de 'Colombia Huila' y se han solicitado 100.",
  "instance": "/v1/pedidos",
  "cafeId": "caf_002",
  "solicitado": 100,
  "disponible": 80
}

Campos definidos por la norma:

Campo Qué es
type URI que identifica el tipo de problema; idealmente apunta a documentación
title Resumen legible, estable para un mismo type
status El código HTTP, repetido en el cuerpo
detail Explicación de esta ocurrencia concreta
instance URI de la ocurrencia
extensiones Campos propios al mismo nivel (cafeId, disponible…)

Ventajas: es estándar, hay bibliotecas que lo generan y lo consumen, y el type como URI garantiza unicidad global. Inconvenientes prácticos: los nombres son crípticos para quien no conoce la RFC, type como URL invita a inventar URLs que nadie mantiene, las extensiones al mismo nivel que los campos estándar pueden colisionar, y el Content-Type distinto obliga a los clientes a manejar dos tipos de respuesta.

8.2. El formato de Tienda Aroma

{
  "error": {
    "codigo": "stock_insuficiente",
    "mensaje": "No hay unidades suficientes de 'Colombia Huila'.",
    "detalles": [
      { "cafeId": "caf_002", "solicitado": 100, "disponible": 80 }
    ]
  }
}
Aspecto problem+json (RFC 9457) Formato de Tienda Aroma
Content-Type application/problem+json application/json
Identificador del tipo type (URI) codigo (snake_case)
Texto para humanos title + detail mensaje
Errores múltiples No previsto de serie detalles como array
Estandarización Alta Propia
Legibilidad para el consumidor Media Alta
Envoltorio Campos en la raíz Todo bajo error

Decisión de Tienda Aroma: formato propio, por tres razones: (1) el envoltorio error hace imposible confundir una respuesta correcta con una errónea, incluso ignorando el código de estado; (2) detalles como array resuelve de forma natural la validación de formularios, que es el caso más frecuente; (3) mantener application/json en toda la API simplifica los clientes. La decisión se documenta explícitamente junto a la alternativa estándar, y en 02-08 quedará reflejada como esquema reutilizable en OpenAPI.

8.3. Reglas del contrato de errores

  1. codigo es contrato. En snake_case, estable, único, y nunca se traduce. Es lo que el software compara.
  2. mensaje es para humanos. Puede cambiar de redacción y puede traducirse (Accept-Language, 02-05). Nunca lo compares en el código.
  3. detalles es un array, siempre presente aunque esté vacío, para que los clientes no tengan que comprobar si existe.
  4. Nada de datos sensibles: ni consultas, ni trazas de pila, ni si un correo existe en la base de datos.
  5. Los 5xx llevan trazaId; los 4xx no lo necesitan.

La implementación de todo esto como middleware de Express es la lección 03-07; aquí solo hemos fijado el contrato.

  1. Catálogo de códigos de error de negocio

El catálogo forma parte del contrato tanto como las URIs. Este es el inicial de Tienda Aroma:

codigo HTTP Cuándo
datos_invalidos 400 Validación de cuerpo o de parámetros
parametro_invalido 400 Query param mal formado (limite=abc)
clave_idempotencia_requerida 400 Falta Idempotency-Key donde es obligatoria
no_autenticado 401 Falta el token o es inválido
token_caducado 401 Token expirado
permisos_insuficientes 403 Identidad válida sin permisos
cafe_no_encontrado 404 El café no existe
cliente_no_encontrado 404 El cliente no existe
pedido_no_encontrado 404 El pedido no existe
resena_no_encontrada 404 La reseña no existe
carrito_no_encontrado 404 El carrito no existe o ha caducado
metodo_no_permitido 405 Método no admitido por el recurso
formato_no_disponible 406 No se puede satisfacer Accept
stock_insuficiente 409 No hay unidades suficientes
pedido_ya_pagado 409 Segundo pago del mismo pedido
pedido_ya_anulado 409 Segunda anulación
pedido_no_enviado 409 Devolución de un pedido no enviado
resena_ya_moderada 409 Segunda moderación de la misma reseña
carrito_vacio 409 Confirmar un carrito sin líneas
operacion_en_curso 409 Petición idéntica todavía procesándose
cafe_descatalogado 410 Café retirado definitivamente
conflicto_version 412 If-Match con ETag antiguo
cuerpo_demasiado_grande 413 Sube el límite de tamaño
formato_no_soportado 415 Content-Type no admitido
clave_idempotencia_reutilizada 422 Misma clave, cuerpo distinto
limite_peticiones 429 Límite superado
error_interno 500 Excepción no controlada
servicio_no_disponible 503 Mantenimiento o saturación

Convención de nombres: <entidad>_<problema> para lo específico (cafe_no_encontrado) y <problema> a secas para lo transversal (datos_invalidos). El catálogo solo crece: retirar un código es un cambio rompedor (02-07).

  1. Antipatrones

10.1. Devolver siempre 200 con exito: false

HTTP/1.1 200 OK

{ "exito": false, "mensaje": "El café no existe" }

Es el antipatrón más extendido y el más dañino. Consecuencias: los clientes HTTP no detectan el error, los reintentos automáticos no se disparan, la monitorización marca 100 % de éxito, los proxys cachean el error como si fuera una respuesta buena, y cada consumidor tiene que inventarse su propia lógica de detección. Renuncia por completo al nivel 2 de Richardson.

10.2. Usar 500 para errores del cliente

Si un POST /v1/cafes con precioEuros: "gratis" provoca 500, el sistema del cliente reintentará una petición condenada al fracaso y tu equipo recibirá una alerta por un fallo ajeno. Regla: si la petición no puede funcionar tal cual, es 4xx.

10.3. Inventar códigos

299 Casi OK, 450 Error de negocio, 600 Fallo. Los intermediarios interpretan los códigos desconocidos por su primera cifra en el mejor de los casos, y los rechazan en el peor. Usa solo códigos registrados; para el detalle de negocio ya tienes el campo codigo del cuerpo.

10.4. Otros que se ven a diario

  • 404 para todo error del cliente, escondiendo 400, 403 y 409 bajo el mismo número.
  • 401 cuando faltan permisos: manda al usuario a autenticarse otra vez, no sirve de nada y a menudo provoca bucles de refresco de token.
  • Mensajes inútiles: "Error", "Algo ha fallado", "Consulta el log".
  • Filtrar la traza de pila en producción.
  • Cuerpo en un 204: hay clientes que no leen el flujo y lo dejarán a medias.

Errores Comunes y Consejos

  • Confundir 401 y 403. No sé quién eres frente a sé quién eres y no puedes. Memorízalo así.
  • Olvidar Location en un 201. El cliente se queda sin la URI del recurso creado.
  • Olvidar Allow en un 405 o WWW-Authenticate en un 401: son obligatorias por norma y hay clientes que dependen de ellas.
  • Usar 409 para errores de validación. 409 es del estado del recurso; los datos malos son 400.
  • Devolver 200 con lista vacía… en un elemento. La colección vacía es 200; el elemento inexistente es 404.
  • Cambiar el código de un endpoint ya publicado. Pasar de 200 a 204 rompe clientes que leen el cuerpo: es un cambio rompedor (02-07).
  • Consejo: escribe la tabla de códigos de cada endpoint antes de implementarlo. Es una columna obligatoria de la documentación de referencia (02-08).
  • Consejo: comprueba tus errores con curl -i. Ver la respuesta cruda descubre Content-Type mal puestos y cuerpos vacíos que un cliente elegante te oculta.

Ejercicios

Ejercicio 1: asignar el código correcto

Indica el código de estado, el codigo de error y las cabeceras relevantes de cada situación:

  1. POST /v1/cafes sin cabecera Authorization.
  2. POST /v1/resenas/res_101/aprobacion con el token de un cliente normal.
  3. POST /v1/pedidos con 100 unidades de caf_002, que tiene stock 80.
  4. GET /v1/cafes/caf_999.
  5. PUT /v1/pedidos/ped_5001 (Tienda Aroma solo admite PATCH ahí).
  6. POST /v1/pedidos/ped_5001/pago sobre un pedido ya pagado.
  7. PATCH /v1/cafes/caf_001 con Content-Type: application/xml.
  8. POST /v1/cafes con {"nombre": "", "precioEuros": -3}.
  9. DELETE /v1/carritos/car_77/lineas/caf_002, correcto.
  10. El servicio interno de stock no responde en 5 segundos.

Ejercicio 2: rediseñar respuestas de una API mal hecha

Una API heredada responde así. Reescribe cada respuesta con el código, las cabeceras y el cuerpo correctos según el contrato de Tienda Aroma.

HTTP/1.1 200 OK
{ "exito": false, "error": "no encontrado" }
HTTP/1.1 200 OK
{ "exito": true, "id": "ped_5001" }        ← respuesta a POST /v1/pedidos
HTTP/1.1 500 Internal Server Error
{ "mensaje": "ValidationError: precioEuros must be positive\n  at validar (/app/src/cafes.js:42:11)" }
HTTP/1.1 403 Forbidden
{ "mensaje": "Debes iniciar sesión" }

Ejercicio 3: traducir a problem+json

Traduce este error de Tienda Aroma al formato application/problem+json de la RFC 9457, y explica qué se gana y qué se pierde en la traducción:

{
  "error": {
    "codigo": "datos_invalidos",
    "mensaje": "El cuerpo de la petición contiene errores de validación.",
    "detalles": [
      { "campo": "precioEuros", "problema": "Debe ser mayor que 0.", "valorRecibido": -3 },
      { "campo": "tueste", "problema": "Valor no permitido.", "valorRecibido": "tostadísimo" }
    ]
  }
}

Soluciones

Solución 1

# Código codigo Cabeceras
1 401 no_autenticado WWW-Authenticate: Bearer realm="..."
2 403 permisos_insuficientes —
3 409 stock_insuficiente — (con detalles: solicitado 100, disponible 80)
4 404 cafe_no_encontrado —
5 405 metodo_no_permitido Allow: GET, PATCH, HEAD, OPTIONS
6 409 pedido_ya_pagado —
7 415 formato_no_soportado Accept-Patch: application/merge-patch+json
8 400 datos_invalidos — (dos entradas en detalles, no una)
9 204 — Sin cuerpo
10 504 servicio_no_disponible — (y trazaId en el cuerpo)

Solución 2

HTTP/1.1 404 Not Found
Content-Type: application/json

{ "error": { "codigo": "cafe_no_encontrado", "mensaje": "No existe ningún café con el identificador 'caf_999'.", "detalles": [] } }
HTTP/1.1 201 Created
Content-Type: application/json
Location: https://api.tiendaaroma.example/v1/pedidos/ped_5001

{
  "id": "ped_5001",
  "clienteId": "cli_842",
  "estado": "pendiente_pago",
  "totalEuros": 29.00,
  "fechaCreacion": "2026-03-14T10:30:00Z",
  "_links": {
    "self": { "href": "/v1/pedidos/ped_5001" },
    "pagar": { "href": "/v1/pedidos/ped_5001/pago", "method": "POST" }
  }
}
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": {
    "codigo": "datos_invalidos",
    "mensaje": "El cuerpo de la petición contiene errores de validación.",
    "detalles": [ { "campo": "precioEuros", "problema": "Debe ser un número mayor que 0.", "valorRecibido": -3 } ]
  }
}

Aquí había dos fallos: el código (un error de validación es del cliente, 400, no 500) y la fuga de la traza de pila con rutas internas del servidor.

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api.tiendaaroma.example"
Content-Type: application/json

{ "error": { "codigo": "no_autenticado", "mensaje": "Se requiere autenticación para acceder a este recurso.", "detalles": [] } }

El mensaje "debes iniciar sesión" delata que el problema es de autenticación, así que el código correcto es 401, no 403, y falta la cabecera WWW-Authenticate.

Solución 3

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "https://api.tiendaaroma.example/errores/datos-invalidos",
  "title": "Datos inválidos",
  "status": 400,
  "detail": "El cuerpo de la petición contiene errores de validación.",
  "instance": "/v1/cafes",
  "errores": [
    { "campo": "precioEuros", "problema": "Debe ser mayor que 0.", "valorRecibido": -3 },
    { "campo": "tueste", "problema": "Valor no permitido.", "valorRecibido": "tostadísimo" }
  ]
}

Se gana: un formato estándar que herramientas y bibliotecas reconocen sin configuración; un type como URI globalmente única que además puede ser un enlace a la documentación del error; y instance, que identifica la ocurrencia concreta y ayuda a correlacionar con los logs.

Se pierde: el envoltorio error, que permitía distinguir de un vistazo una respuesta correcta de una errónea sin mirar el código; la homogeneidad del Content-Type en toda la API; y la nitidez de los nombres —codigo/mensaje/detalles son más directos para el consumidor que type/title/detail—. Además, la lista de errores de validación (errores) es una extensión propia en ambos casos: la RFC no la estandariza, así que esa parte hay que documentarla igual.

Conclusión

Los códigos de estado son la primera línea del contrato: dicen si la operación fue bien, de quién es el problema y si tiene sentido reintentar. Ahora sabes cuándo devolver 201 con Location y cuándo 204 sin cuerpo, distinguir 401 de 403 y 404 de 410, reservar 409 para los conflictos de estado como el stock_insuficiente, no confundir 406 con 415, y acompañar los 5xx de Retry-After y de un trazaId sin filtrar nada interno. Tienes además un árbol de decisión reutilizable, el catálogo de códigos de error de negocio de Tienda Aroma y una postura razonada frente a application/problem+json. Y sabes qué no hacer: 200 con exito: false, 500 por datos mal escritos por el cliente y códigos inventados.

Hasta aquí hemos diseñado el sobre: dónde va la petición, con qué verbo y con qué resultado. Falta el contenido. En la lección siguiente, 02-05 Representaciones, cabeceras y negociación de contenido, diseñaremos el cuerpo de las respuestas de Tienda Aroma: nombres y tipos de campo, fechas, importes monetarios, nulos frente a ausentes, el envoltorio datos/total, cuándo incrustar y cuándo enlazar con _links, la expansión y la selección de campos, y toda la negociación de contenido con Accept, Content-Type, Accept-Language y Accept-Encoding, incluida la factura en PDF y la subida de imágenes de café.

Curso de REST API: Principios de Diseño y Desarrollo de APIs RESTful

Módulo 1: Introducción a las APIs RESTful

Módulo 2: Diseño de APIs RESTful

Módulo 3: Desarrollo de APIs RESTful

Módulo 4: Buenas Prácticas y Seguridad

Módulo 5: Herramientas y Frameworks

Módulo 6: Casos de Estudio y Proyectos

© Copyright 2026. Todos los derechos reservados