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
- Las cinco familias y por qué importan
- La familia 2xx: éxito
- La familia 3xx: redirección
- La familia 4xx: error del cliente
- La familia 5xx: error del servidor
- Tabla maestra de Tienda Aroma
- Árbol de decisión: cómo elegir el código correcto
- El cuerpo del error:
problem+jsony el formato de Tienda Aroma - Catálogo de códigos de error de negocio
- Antipatrones
- 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.
- 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.
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.
HTTP/1.1 206 Partial Content
Content-Type: application/pdf
Content-Range: bytes 24000-48212/48213
Content-Length: 24213No se usa para paginar JSON: para eso están los mecanismos de 02-06.
- 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.
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.
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.
- 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 elAuthorization, el token es inválido o ha caducado. La respuesta debe incluirWWW-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 enAccept(salida).415→ el servidor no entiende lo que el cliente envía enContent-Type(entrada).
HTTP/1.1 406 Not Acceptable
Content-Type: application/json
{ "error": { "codigo": "formato_no_disponible", "mensaje": "Solo se admite application/json.", "detalles": [] } }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.
- 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.
- 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 |
- Á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.
- El cuerpo del error:
problem+json y el formato de Tienda Aroma
problem+json y el formato de Tienda AromaEl 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
codigoes contrato. Ensnake_case, estable, único, y nunca se traduce. Es lo que el software compara.mensajees para humanos. Puede cambiar de redacción y puede traducirse (Accept-Language, 02-05). Nunca lo compares en el código.detalleses un array, siempre presente aunque esté vacío, para que los clientes no tengan que comprobar si existe.- Nada de datos sensibles: ni consultas, ni trazas de pila, ni si un correo existe en la base de datos.
- Los
5xxllevantrazaId; los4xxno lo necesitan.
La implementación de todo esto como middleware de Express es la lección 03-07; aquí solo hemos fijado el contrato.
- 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).
- Antipatrones
10.1. Devolver siempre 200 con exito: false
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
404para todo error del cliente, escondiendo400,403y409bajo el mismo número.401cuando 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
Locationen un201. El cliente se queda sin la URI del recurso creado. - Olvidar
Allowen un405oWWW-Authenticateen un401: son obligatorias por norma y hay clientes que dependen de ellas. - Usar
409para errores de validación.409es del estado del recurso; los datos malos son400. - Devolver
200con lista vacía… en un elemento. La colección vacía es200; el elemento inexistente es404. - Cambiar el código de un endpoint ya publicado. Pasar de
200a204rompe 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 descubreContent-Typemal 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:
POST /v1/cafessin cabeceraAuthorization.POST /v1/resenas/res_101/aprobacioncon el token de un cliente normal.POST /v1/pedidoscon 100 unidades decaf_002, que tiene stock 80.GET /v1/cafes/caf_999.PUT /v1/pedidos/ped_5001(Tienda Aroma solo admitePATCHahí).POST /v1/pedidos/ped_5001/pagosobre un pedido ya pagado.PATCH /v1/cafes/caf_001conContent-Type: application/xml.POST /v1/cafescon{"nombre": "", "precioEuros": -3}.DELETE /v1/carritos/car_77/lineas/caf_002, correcto.- 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 500 Internal Server Error
{ "mensaje": "ValidationError: precioEuros must be positive\n at validar (/app/src/cafes.js:42:11)" }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
- ¿Qué es una API?
- Historia y evolución de las APIs
- Fundamentos de HTTP para APIs
- Principios básicos de REST
- Modelo de madurez de Richardson y HATEOAS
- REST vs. SOAP
- REST frente a GraphQL, gRPC y webhooks
Módulo 2: Diseño de APIs RESTful
- Principios de diseño de APIs RESTful
- Recursos y URIs
- Métodos HTTP
- Códigos de estado HTTP
- Representaciones, cabeceras y negociación de contenido
- Filtrado, ordenación, paginación y búsqueda
- Versionado de APIs
- Documentación de APIs
Módulo 3: Desarrollo de APIs RESTful
- Configuración del entorno de desarrollo
- Creación de un servidor básico
- Manejo de peticiones y respuestas
- Validación de datos de entrada
- Persistencia y capa de acceso a datos
- Autenticación y autorización
- Manejo de errores
- Pruebas y validación
Módulo 4: Buenas Prácticas y Seguridad
- Buenas prácticas en el diseño de APIs
- Seguridad en APIs RESTful
- OAuth 2.0 y OpenID Connect en la práctica
- Rate limiting y throttling
- CORS y políticas de seguridad
- Caché HTTP y rendimiento
- Observabilidad: logs, métricas y trazas
Módulo 5: Herramientas y Frameworks
- Postman para pruebas de APIs
- Swagger y OpenAPI para documentación
- Frameworks populares para APIs RESTful
- Contratos, mocks y pruebas automatizadas de API
- Integración continua y despliegue
- API gateways y portales de desarrollador
