En 06-02 comparamos el diseño de Tienda Aroma con el de CafeSocial y cerramos con una tesis incómoda: no existe un diseño REST universal. Ahora llega el último asunto, el que separa una API bien diseñada de una API que sigue viva a los tres años: qué ocurre después de publicarla. Porque el día del lanzamiento la API es tuya; a partir de la primera integración es de tus consumidores, y cada campo que devuelves se convierte en una promesa. Esta lección recorre los tres primeros años de la API de Tienda Aroma como una línea temporal con hitos concretos: el primer incidente, el post-mortem de la caída de Navidad, la deuda de contrato que se va acumulando, la migración a v2 con su calendario de doce meses y el trabajo silencioso de mantenimiento que nadie ve pero que sostiene todo lo demás.

Contenido

  1. La línea temporal de una API en producción
  2. El primer día: qué se vigila y qué es normal
  3. Gestión de incidentes: severidades, mitigación y comunicación
  4. El post-mortem sin culpables (documento completo)
  5. Escuchar a los consumidores
  6. La deuda de contrato
  7. Añadir sin romper: catálogo de cambios seguros
  8. Cuándo toca una v2 y cómo se migra en 12 meses
  9. Retirar funcionalidades y el coste de lo que casi nadie usa
  10. Mantenimiento continuo: dependencias, secretos, SLO y coste
  11. Inventario de APIs y APIs zombis
  12. Gobierno con varios equipos
  13. Errores comunes, ejercicios y conclusión

  1. La línea temporal de una API en producción

Conviene ver el ciclo completo antes de entrar en cada hito. Estos son los momentos que marcaron los tres primeros años de https://api.tiendaaroma.example/v1.

graph LR
    A["Mes 0<br/>Lanzamiento v1<br/>SPA + panel"] --> B["Mes 2<br/>Aroma Movil<br/>1er incidente S3"]
    B --> C["Mes 5<br/>RapidoEnvios<br/>webhooks HMAC"]
    C --> D["Mes 7<br/>Caida de Navidad<br/>34 min - post-mortem"]
    D --> E["Mes 11<br/>Registro de deuda<br/>de contrato"]
    E --> F["Ano 2 - mes 14<br/>CataBox OAuth<br/>+ analitica por cliente"]
    F --> G["Ano 2 - mes 18<br/>Decision: v2"]
    G --> H["Ano 2 - mes 20<br/>Anuncio + guia<br/>de migracion"]
    H --> I["Ano 3 - mes 26<br/>Brownouts<br/>programados"]
    I --> J["Ano 3 - mes 32<br/>Apagado v1<br/>410 retirada"]

Ninguno de estos hitos es un proyecto nuevo: todos son mantenimiento. Y el mantenimiento consume, en una API con varios consumidores externos, bastante más esfuerzo acumulado que la construcción inicial.


  1. El primer día: qué se vigila y qué es normal

El mes 0 la API se publica con dos consumidores propios: la SPA https://tiendaaroma.example y el panel interno. Todo el instrumental que montamos en 04-07 (pino para logs estructurados, prom-client para métricas, trazas con OpenTelemetry, /salud/vivo y /salud/listo) deja de ser un ejercicio y pasa a ser el único sitio donde se puede mirar.

Las cuatro señales que se vigilan desde el minuto uno son las clásicas de un servicio de petición-respuesta:

Señal Qué mide Normal en el mes 0 Alerta si
Tasa de error 5xx error_interno, servicio_no_disponible < 0,1 % de las peticiones > 0,5 % durante 5 min
Latencia p95 Tiempo de respuesta por ruta 120–180 ms en GET /cafes > 400 ms durante 10 min
Saturación Conexiones a SQLite, memoria, event loop Estable Lag del event loop > 100 ms
Tráfico Peticiones por minuto y por consumidor Crece de forma suave Salto x5 sin campaña conocida

Y hay una quinta señal que casi nadie mira el primer día y que resulta ser la más informativa: la distribución de los 4xx.

# Top de errores de cliente de las ultimas 24 h, agrupados por codigo del catalogo
# (los logs de pino salen en JSON, asi que jq basta)
cat logs/api-*.log \
  | jq -r 'select(.res.statusCode >= 400 and .res.statusCode < 500)
           | "\(.res.statusCode) \(.error.codigo // "sin_codigo") \(.req.url | split("?")[0])"' \
  | sort | uniq -c | sort -rn | head -20

Salida real del segundo día:

   412 400 parametro_invalido   /v1/cafes
   118 401 no_autenticado       /v1/pedidos
    97 404 cafe_no_encontrado   /v1/cafes/{id}
    31 409 conflicto_version    /v1/cafes/{id}

Qué es normal y qué no. Los 401 son normales: la SPA reintenta con el token caducado y renueva. Los 404 sobre /cafes/{id} también: hay enlaces antiguos indexados. Lo que no es normal son 412 parametro_invalido en GET /v1/cafes: eso no es un cliente torpe, es un contrato mal explicado. Al inspeccionar los detalles del error aparece el patrón: los clientes envían ?tueste=Medio con mayúscula inicial, y nuestro enumerado sólo acepta claro|medio|oscuro. La documentación lo decía; el mensaje de error, no. Se corrigió el mensaje —no la validación— y los 400 cayeron a 20 al día.

Regla del primer día: un error 4xx repetido no acusa al cliente, describe un defecto de diseño o de documentación de tu API.

El primer incidente (mes 2). Con el lanzamiento de Aroma Móvil aparecen los primeros 429. La app hace polling del carrito cada 5 segundos y el rate limiting de 04-04 empieza a devolver limite_peticiones con Retry-After. El diagnóstico tardó veinte minutos porque las cabeceras Aroma-RateLimit-Restantes no se estaban registrando en los logs. La solución no fue subir el límite: fue añadir ETag al carrito (ya lo teníamos en /cafes, ver 04-05) para que el polling respondiera 304 Not Modified a coste casi nulo, y publicar en la guía de integración que el intervalo recomendado era de 30 segundos.


  1. Gestión de incidentes: severidades, mitigación y comunicación

Detección: el presupuesto de error manda

En 04-07 fijamos un SLO de disponibilidad del 99,9 % mensual para las rutas de lectura. Ese 0,1 % son 43 minutos de presupuesto de error al mes. El presupuesto es lo que convierte una discusión de opiniones ("¿esto es grave?") en una decisión aritmética: si en tres días se ha consumido el 60 % del presupuesto mensual, se congelan los despliegues de funcionalidad y el equipo se dedica a fiabilidad hasta que la tasa se recupere.

Las alertas no se disparan sobre umbrales instantáneos, sino sobre velocidad de consumo del presupuesto (burn rate): consumir el presupuesto 14 veces más rápido de lo sostenible durante 5 minutos es un aviso inmediato; 6 veces más rápido durante una hora, un aviso normal.

Niveles de severidad

Sev Definición Ejemplo en Tienda Aroma Respuesta Comunicación
S1 API caída o compras imposibles para todos POST /pedidos/{id}/pago devuelve 500 al 100 % Guardia inmediata, sala de incidente Página de estado en < 15 min + aviso a socios
S2 Degradación grave o funcionalidad crítica rota para un consumidor Aroma Móvil recibe conflicto_version en cada actualización Guardia inmediata en horario extendido Página de estado + correo al consumidor afectado
S3 Degradación parcial sin pérdida de datos p95 de /cafes a 900 ms; búsquedas lentas Siguiente día laborable Nota en el portal de desarrollador
S4 Defecto menor, contrato incumplido sin impacto Retry-After ausente en un 429 concreto Backlog priorizado Changelog

Mitigar antes que diagnosticar

Es la regla más difícil de interiorizar para un equipo técnico, porque la curiosidad empuja hacia el por qué. Durante un incidente, el orden correcto es:

  1. Restaurar el servicio con lo que sea: revertir el último despliegue, apagar la feature flag (05-04), devolver el tráfico al color anterior en el blue-green, degradar una funcionalidad.
  2. Preservar evidencias: capturar trazas, Aroma-Traza-Id de peticiones fallidas, salida de EXPLAIN, métricas del intervalo.
  3. Después, entender la causa raíz con calma.

Un despliegue revertido en 4 minutos cuesta un post-mortem; un despliegue depurado en caliente durante 40 minutos cuesta el presupuesto de error del trimestre.

Qué se comunica a los consumidores

Con consumidores externos —RápidoEnvíos y CataBox—, callar es peor que equivocarse. El patrón que adoptamos: una entrada en la página de estado en menos de 15 minutos aunque no se sepa nada ("estamos investigando errores en la creación de pedidos"), actualizaciones cada 30 minutos, y una nota final con el enlace al post-mortem cuando esté publicado. Nada de detalles internos de infraestructura; sí el impacto observable y el consejo de reintento.


  1. El post-mortem sin culpables

Mes 7, 18 de diciembre. La campaña de Navidad multiplica el tráfico por seis. Dos semanas antes se había añadido el filtro ?origen= a GET /v1/cafes —un cambio retrocompatible de manual, tres líneas de código— pero sin índice en la columna correspondiente. Con el catálogo crecido y el tráfico de campaña, la base de datos se satura y la API queda inaccesible 34 minutos.

Este es el documento tal como quedó en docs/incidentes/. Fíjate en lo que no contiene: ningún nombre asociado a la causa.

# Post-mortem INC-041: saturacion de la base de datos por filtro sin indice

- **Estado:** cerrado
- **Severidad:** S1
- **Fecha:** 18 de diciembre, 19:42 - 20:16 (CET)
- **Duracion del impacto:** 34 minutos
- **Autor:** equipo de plataforma de API
- **Revisado por:** producto, soporte, seguridad

## Resumen

Un filtro anadido dos semanas antes (`GET /v1/cafes?origen=`) provocaba un escaneo
completo de la tabla `cafes`. Con el trafico de la campana de Navidad (x6 sobre la
media) las consultas agotaron el pool de conexiones y toda la API dejo de responder,
incluidas rutas que no usaban ese filtro.

## Cronologia (hora CET)

| Hora | Evento |
|---|---|
| 04/12 10:15 | Se despliega el filtro `origen` en `/v1/cafes`. Sin indice. Sin carga de prueba. |
| 18/12 19:31 | Empieza el envio de la newsletter de Navidad con enlaces a `?origen=etiopia`. |
| 18/12 19:38 | p95 de `/v1/cafes` pasa de 160 ms a 2,4 s. Nadie mira. |
| 18/12 19:42 | Alerta de burn rate 14x. Comienza el impacto medible. |
| 18/12 19:44 | La guardia acusa recibo. Se abre la sala de incidente. |
| 18/12 19:47 | Se publica la primera nota en la pagina de estado ("investigando"). |
| 18/12 19:53 | Se descarta el ultimo despliegue (del 17/12) como causa: revertirlo no cambia nada. |
| 18/12 20:01 | Se identifica en las trazas que el 88 % del tiempo se va en una unica consulta. |
| 18/12 20:04 | **Mitigacion:** se desactiva el filtro `origen` con la feature flag y se devuelve
                `400 parametro_invalido` temporalmente para ese parametro. |
| 18/12 20:09 | La latencia p95 vuelve a 210 ms. El pool se recupera. |
| 18/12 20:16 | Fin del impacto. Nota de resolucion en la pagina de estado. |
| 18/12 21:30 | Se crea el indice en una ventana de baja carga y se reactiva el filtro. |

## Impacto medido

- 34 minutos de indisponibilidad parcial-total (79 % de las peticiones con 5xx o timeout).
- 41.200 peticiones fallidas; 218 intentos de `POST /v1/pedidos` sin completar.
- 96 pedidos no cerrados durante la ventana; 61 se recuperaron solos por reintento
  del cliente gracias a `Idempotency-Key` (no hubo cobros duplicados).
- Presupuesto de error mensual consumido: 79 % (34 min sobre 43 min disponibles).
- 7 tickets de soporte y 1 aviso de RapidoEnvios por webhooks de envio retrasados.

## Causa raiz

La consulta generada por el filtro `origen` no disponia de indice y realizaba un
escaneo secuencial de `cafes`. Bajo concurrencia alta, cada consulta mantenia su
conexion ocupada el tiempo suficiente para agotar el pool, de modo que peticiones
ajenas al filtro (`/v1/pedidos`, `/v1/clientes`) tambien quedaban en espera.

Causa contribuyente: la revision del cambio se centro en el contrato (nombre del
parametro, validacion, documentacion en `openapi.yaml`) y no en su plan de ejecucion.
No existia ninguna comprobacion automatica que lo exigiese.

## Que fallo en la deteccion

- La degradacion empezo a las 19:38 y la alerta salto a las 19:42: cuatro minutos
  perdidos porque la alerta de latencia solo miraba el agregado global, no por ruta.
- No habia alerta sobre saturacion del pool de conexiones, que era la senal mas
  temprana y la mas inequivoca.
- El envio de la newsletter no estaba anunciado al equipo de plataforma.

## Que hicimos bien

- La feature flag permitio mitigar sin desplegar codigo.
- La idempotencia evito cobros duplicados en los reintentos.
- La pagina de estado se actualizo antes de tener diagnostico.

## Acciones

| # | Accion | Tipo | Responsable | Plazo |
|---|---|---|---|---|
| 1 | Crear indice `idx_cafes_origen` y validar con EXPLAIN QUERY PLAN | Correctiva | Equipo datos | 19/12 (hecho) |
| 2 | Alerta de latencia p95 **por ruta**, no solo agregada | Deteccion | Plataforma | 09/01 |
| 3 | Alerta de saturacion del pool de conexiones al 80 % | Deteccion | Plataforma | 09/01 |
| 4 | Anadir a la checklist de revision: "todo filtro nuevo declara su indice" | Preventiva | Gobierno API | 15/01 |
| 5 | Prueba de carga automatica en CI para rutas de listado | Preventiva | Plataforma | 31/01 |
| 6 | Calendario compartido de campanas de marketing con plataforma | Organizativa | Producto | 15/01 |
| 7 | Publicar resumen del incidente en el portal de desarrollador | Comunicacion | Soporte | 22/12 (hecho) |

## Que NO es una accion

"Tener mas cuidado al revisar" no es una accion: no es verificable ni deja rastro.
Si una accion no se puede cerrar con un enlace a un commit, a un tablero o a un
documento, no entra en esta tabla.

La verificación de la acción 1, para que quede claro qué se comprueba:

-- Antes: escaneo completo de la tabla
EXPLAIN QUERY PLAN
SELECT * FROM cafes WHERE origen = 'etiopia' ORDER BY nombre LIMIT 20;
-- SCAN cafes

CREATE INDEX idx_cafes_origen ON cafes (origen, nombre);

-- Despues: busqueda por indice
EXPLAIN QUERY PLAN
SELECT * FROM cafes WHERE origen = 'etiopia' ORDER BY nombre LIMIT 20;
-- SEARCH cafes USING INDEX idx_cafes_origen (origen=?)

Sin culpables no significa sin responsables. Las acciones tienen dueño y plazo; la causa no tiene dueño. La persona que añadió el filtro sin índice hizo lo que el sistema le permitía hacer: no había checklist, ni prueba de carga, ni alerta. El sistema falló, y el sistema es lo que se arregla.


  1. Escuchar a los consumidores

A partir del mes 9, la pregunta deja de ser "¿funciona?" y pasa a ser "¿qué están haciendo realmente con ella?". Tres fuentes:

Analítica de uso por endpoint y por cliente. Cada token JWT lleva un client_id (04-02). Se etiqueta la métrica de peticiones con ese identificador, pero con cuidado con la cardinalidad: etiquetar por ruta con parámetros (/v1/cafes/caf_001) genera una serie temporal por café y hace estallar la memoria de Prometheus. Se etiqueta por plantilla de ruta y por cliente, que son conjuntos pequeños y acotados.

// metricas/peticiones.js — cardinalidad controlada a proposito
import client from 'prom-client';

const peticiones = new client.Counter({
  name: 'aroma_peticiones_total',
  help: 'Peticiones atendidas por la API',
  // ruta = PLANTILLA (/v1/cafes/:id), nunca la ruta concreta.
  // cliente = client_id del JWT, un conjunto cerrado de ~6 consumidores.
  // version = v1 | v2, imprescindible para la migracion (apartado 8).
  labelNames: ['metodo', 'ruta', 'codigo', 'cliente', 'version'],
});

export function contarPeticion(req, res) {
  peticiones.inc({
    metodo: req.method,
    ruta: req.route?.path ?? 'desconocida',   // plantilla, no URL real
    codigo: res.statusCode,
    cliente: req.auth?.clientId ?? 'anonimo', // etiqueta acotada
    version: req.baseUrl.startsWith('/v2') ? 'v2' : 'v1',
  });
}

Qué campos no usa nadie. Una API REST devuelve la representación completa, así que no sabes qué lee el cliente… salvo que se lo preguntes. Dos técnicas baratas: (a) medir el uso del parámetro de proyección si lo tienes (?campos=), y (b) preguntar directamente en la encuesta anual a integradores. En Tienda Aroma descubrimos así que notasCata sólo lo consumían la SPA y CataBox, y que el campo _links.self de cada elemento de colección no lo usaba absolutamente nadie: los clientes construían las URLs por concatenación. Dato incómodo que se apuntó en la autocrítica de HATEOAS de 06-01.

Errores 4xx recurrentes por cliente. Es la mejor lista de tareas de documentación que existe:

Error repetido Cliente Interpretación real Acción tomada
parametro_invalido en ordenar CataBox El separador -precioEuros no estaba en los ejemplos Ejemplo añadido en openapi.yaml
conflicto_version en PUT /cafes/{id} Panel interno No reenviaba el ETag tras un fallo Nota en la guía + detalles más explícitos
stock_insuficiente en pago Aroma Móvil Aviso de stock demasiado tardío Deuda de diseño (ver apartado 6)
no_autenticado masivo a las 03:00 RápidoEnvíos Renovación de token mal programada Correo directo al socio

Y el canal de soporte: una dirección [email protected] que llega a un humano, con el compromiso público de responder en 2 días laborables. Cada ticket se etiqueta como defecto, documentación o petición de funcionalidad; los de documentación se cierran editando el openapi.yaml, nunca respondiendo sólo por correo.


  1. La deuda de contrato

Mes 11. El equipo hace una lista de "cosas que arreglaríamos si empezáramos hoy" y se da cuenta de que ninguna se puede arreglar. Eso es deuda de contrato: decisiones publicadas que ya no son las mejores, pero que sostienen a consumidores reales.

Se acumula por tres motivos, y ninguno es negligencia:

  • El dominio cambia. Cuando se diseñó tueste con tres valores no existía el tueste filtro en el catálogo.
  • El estándar cambia o se conoce mejor. application/problem+json (RFC 9457) existía, pero se optó por un formato propio; hoy sería la opción obvia.
  • Aciertas al 80 %. El envoltorio {"datos": [...], "total": n} funcionó bien, pero encarece cada respuesta y confunde a los clientes que esperan un array desnudo.

En 06-01 ya criticamos cuatro decisiones. Ahora tienen precio:

Deuda Cambio deseado Por qué no se puede hacer en v1 Coste de convivir
Nombre poco claro notasCatanotasDeCata Rompe a los 5 consumidores Bajo: confunde a los nuevos
Envoltorio Quitar datos / usar sólo Link Rompe todo el parseo de colecciones Medio: código duplicado en clientes
Formato de error Adoptar problem+json Cambia el Content-Type y la forma del cuerpo Medio: fricción con librerías estándar
Reseñas con dos rutas Dejar sólo /cafes/{id}/resenas La SPA usa /resenas?cafeId= Alto: dos caminos que mantener y cachear
Aviso tardío de stock Validar stock al añadir al carrito Cambia la semántica de POST /carritos/{id}/items Alto: soporte recurrente

El artefacto que hace que esto no se olvide es el registro de deuda de contrato, versionado junto al código en docs/deuda-contrato.yaml. Es un documento vivo: se revisa cada trimestre y es la materia prima de la decisión sobre v2.

# docs/deuda-contrato.yaml — se revisa cada trimestre
deudas:
  - id: DC-004
    titulo: "El aviso de stock insuficiente llega en el pago, no en el carrito"
    origen: "Post-mortem INC-041 y 38 tickets de soporte"
    consumidores_afectados: [spa-tienda, aroma-movil]
    impacto: alto
    solucion_deseada: >
      Validar stock en POST /carritos/{id}/items y devolver 409 stock_insuficiente
      en ese momento, manteniendo la validacion final en el pago.
    rompe_contrato: true   # cambia el codigo de estado en un caso antes valido
    candidata_v2: true
    creada: "ano 1, mes 11"
    revisada: "ano 2, mes 18"

  - id: DC-007
    titulo: "Formato de error propio en lugar de application/problem+json"
    consumidores_afectados: [todos]
    impacto: medio
    rompe_contrato: true
    candidata_v2: true
    nota: >
      Mitigacion posible sin romper: negociacion de contenido. Si el cliente envia
      Accept: application/problem+json devolvemos ese formato; si no, el propio.

Fíjate en la nota de DC-007: parte de la deuda se puede pagar sin romper nada si se piensa en términos de negociación de contenido (02-05). No toda deuda exige una versión nueva.


  1. Añadir sin romper: catálogo de cambios seguros

Antes de plantearse una v2, hay que agotar lo que se puede hacer dentro de v1. La regla general la vimos en 02-07: añadir es seguro, quitar y cambiar el significado no lo es. El detalle importa mucho más de lo que parece.

Cambio ¿Rompe? Solución retrocompatible
Nuevo campo origenCertificado en Cafe No* Añadirlo opcional y documentarlo; los clientes que no lo conocen lo ignoran
Nuevo endpoint /cafes/{id}/lotes No Publicar y documentar
Nuevo filtro ?certificado=true No Opcional, con valor por defecto = comportamiento actual
Nuevo valor filtro en el enumerado tueste (respuesta) Sí, en la práctica Introducir con aviso previo; los clientes con switch exhaustivo fallan
Nuevo valor aceptado en tueste (petición) No Ampliar la validación de entrada es seguro
Endurecer una validación existente Fase de aviso: registrar, no rechazar; luego rechazar
Cambiar el valor por defecto de limite de 20 a 50 No cambiarlo; o cambiarlo sólo para clientes nuevos
Renombrar notasCata Duplicar campo + deprecar el viejo, o esperar a v2
Quitar un campo Sólo en v2
Cambiar 200 por 202 en una operación Sólo en v2
Hacer obligatorio un campo de entrada opcional Sólo en v2

* El asterisco del campo nuevo. "Añadir un campo no rompe nada" sólo es cierto si los clientes son tolerant readers: si ignoran lo que no conocen. Un cliente que valide la respuesta contra un esquema estricto con additionalProperties: false, o que use un lenguaje que falle al deserializar campos desconocidos, se rompe con un campo nuevo. Por eso la guía de integración de Tienda Aroma dice esto en la primera página:

// Contrato de tolerancia publicado en la guia de integracion.
// Asi debe leer un cliente la respuesta de GET /v1/cafes/caf_001

const cafe = await respuesta.json();

// BIEN: se leen los campos conocidos y se ignora el resto.
const vista = {
  id: cafe.id,
  nombre: cafe.nombre,
  precioEuros: cafe.precioEuros,
  // Valor desconocido en un enumerado: se degrada, no se revienta.
  tueste: ['claro', 'medio', 'oscuro'].includes(cafe.tueste) ? cafe.tueste : 'otro',
};

// MAL: rompe en cuanto anadimos origenCertificado.
// const { id, nombre, precioEuros, ...resto } = cafe;
// if (Object.keys(resto).length > 0) throw new Error('Campo desconocido');

El caso peligroso: endurecer una validación. En el mes 14 se descubrió que notasCata admitía textos de cualquier longitud y alguien había guardado 40 KB. La tentación es añadir .max(500) en el esquema Zod (03-02) y desplegar. Eso convierte peticiones antes válidas en 422 datos_invalidos: es un cambio rompedor aunque no toque ni un nombre de campo. El procedimiento correcto es en dos tiempos.

// Fase 1 (semanas 1-6): modo aviso. No se rechaza nada, se mide quien incumpliria.
const esquemaCafe = z.object({
  nombre: z.string().min(1).max(120),
  notasCata: z.string(),   // sin limite todavia
  // ...
}).superRefine((datos, ctx) => {
  if (datos.notasCata.length > 500) {
    // Se registra con el cliente para poder avisarle uno a uno.
    log.warn({
      evento: 'validacion_futura_incumplida',
      regla: 'notasCata_max_500',
      longitud: datos.notasCata.length,
      cliente: ctx.path,
    }, 'Peticion que sera rechazada a partir del 1 de marzo');
    contadorAvisos.inc({ regla: 'notasCata_max_500' });
  }
});

// Fase 2 (semana 7, solo si el contador esta a cero durante 14 dias):
// notasCata: z.string().max(500)

Si al cabo de seis semanas el contador sigue subiendo, no se despliega la fase 2: se llama al cliente. La fecha se mueve; el contrato no se rompe por sorpresa.


  1. Cuándo toca una v2 y cómo se migra en 12 meses

La respuesta correcta casi siempre es "todavía no"

Una v2 no es un logro, es una factura: dos bases de código o dos capas de traducción, dos juegos de pruebas, dos documentaciones, dos versiones del openapi.yaml, y consumidores que tardarán meses en moverse. Criterios honestos:

Señales de que NO toca v2:

  • El cambio se puede hacer añadiendo (apartado 7).
  • Molesta al equipo pero no a los consumidores (notasCata por sí solo no justifica nada).
  • Es un problema de documentación disfrazado de problema de diseño.
  • Hay menos de tres deudas de impacto alto acumuladas.

Señales de que SÍ toca:

  • Varias deudas de impacto alto que sólo se resuelven rompiendo, y que causan incidentes o soporte recurrente.
  • El modelo de dominio ha cambiado de verdad (Tienda Aroma pasó a vender suscripciones, y un pedido recurrente no encaja en el recurso /pedidos actual).
  • Cambios de seguridad no negociables.
  • El coste de mantener los apaños supera el coste de migrar.

Mes 18. Tienda Aroma decide la v2 con cuatro deudas de impacto alto y un dominio nuevo (suscripciones). Se escribe un ADR en docs/decisiones/0031-lanzar-v2.md con la alternativa descartada (seguir parcheando v1 con problem+json negociado y un recurso /suscripciones colgado de v1) y por qué se rechaza.

Calendario de 12 meses

Recordemos la política publicada: versionado sólo en la ruta, y v1 y v2 conviven al menos 6 meses. En la práctica, para una API con socios externos, seis meses es el mínimo legal y doce el mínimo razonable.

Mes Hito Qué ve el consumidor
M0 Anuncio + guía de migración + v2 en beta Correo, portal, changelog, entorno de pruebas
M1 v2 estable en producción Ambas versiones funcionando
M1 v1 marcada como deprecada Cabeceras Deprecation, Sunset, Link rel="successor-version"
M2–M5 Acompañamiento Soporte prioritario a integradores, ejemplos, sesiones técnicas
M6 Primer corte de métricas Informe interno: quién sigue en v1
M7 Contacto directo con rezagados Llamada, no correo automático
M9 Brownout 1: 30 min de 410 en v1 Aviso 2 semanas antes; ventana de baja carga
M10 Brownout 2: 2 h de 410 Aviso 2 semanas antes
M11 Brownout 3: 8 h de 410 Aviso 2 semanas antes
M12 Apagado definitivo de v1 410 Gone permanente con version_api_retirada
M12+3 Borrado del código de v1

Guía de migración: la tabla de equivalencias

Es el documento que decide si la migración es de dos días o de dos meses para el consumidor. Nada de prosa: equivalencias literales.

v1 v2 Nota
GET /v1/cafes{"datos":[…],"total":n} GET /v2/cafes{"items":[…],"paginacion":{…}} El total exacto pasa a ser opcional
notasCata notasDeCata Renombrado
tueste: claro|medio|oscuro tueste: claro|medio|oscuro|filtro Valor nuevo
Error propio {"error":{…}} application/problem+json codigotype (URI del catálogo)
GET /v1/resenas?cafeId= GET /v2/cafes/{id}/resenas Ruta única
POST /v1/carritos/{id}/items (sin control de stock) Igual, pero puede devolver 409 stock_insuficiente Aviso temprano
GET /v2/suscripciones Recurso nuevo
?desplazamiento= ?cursor= desplazamiento se acepta 6 meses en v2

Las cabeceras de deprecación en funcionamiento

Retomando 02-07, así responde v1 a partir del mes 1:

GET /v1/cafes?origen=etiopia HTTP/1.1
Host: api.tiendaaroma.example
Authorization: Bearer eyJhbGciOi...

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Deprecation: @1836345600
Sunset: Sat, 12 Jun 2027 00:00:00 GMT
Link: <https://api.tiendaaroma.example/v2/cafes?origen=etiopia>; rel="successor-version",
      <https://docs.tiendaaroma.example/migracion-v2>; rel="deprecation"; type="text/html"
Aroma-Traza-Id: trz_9f2a41c8
// middleware/deprecacion.js — se aplica a todo el router de /v1
const SUNSET = new Date('2027-06-12T00:00:00Z');
const DEPRECATION_UNIX = Math.floor(new Date('2026-06-12T00:00:00Z').getTime() / 1000);

export function deprecarV1(req, res, siguiente) {
  // Deprecation: cuando se DECLARO obsoleta (formato IMF-fecha o marca @unix).
  res.set('Deprecation', `@${DEPRECATION_UNIX}`);
  // Sunset: cuando dejara de responder. Es una promesa; no se adelanta jamas.
  res.set('Sunset', SUNSET.toUTCString());
  res.append('Link',
    `<https://api.tiendaaroma.example/v2${req.path}>; rel="successor-version"`);
  res.append('Link',
    '<https://docs.tiendaaroma.example/migracion-v2>; rel="deprecation"; type="text/html"');
  siguiente();
}

Saber quién sigue en v1

Con la etiqueta version del contador del apartado 5, la pregunta se responde sola:

# Consumidores que aun usan v1 en los ultimos 7 dias, ordenados por volumen
curl -sG "http://prometheus.interno:9090/api/v1/query" \
  --data-urlencode 'query=topk(10, sum by (cliente) (increase(aroma_peticiones_total{version="v1"}[7d])))' \
  | jq -r '.data.result[] | "\(.metric.cliente)\t\(.value[1] | tonumber | floor)"'
catabox            412803
integracion-erp     18744   <- nadie sabia que existia (ver apartado 11)
aroma-movil          2210   <- version antigua de la app, sin actualizar

Ese segundo resultado es el motivo por el que no se apaga una versión por calendario sin mirar las métricas: aparece siempre un consumidor olvidado. Y el tercero recuerda que en las apps móviles no controlas cuándo actualiza el usuario: la app antigua seguirá viva durante meses.

Brownouts

Un brownout es un apagado breve y anunciado de v1: durante la ventana, todas las peticiones reciben 410. Su función no es técnica, es psicológica: convierte una fecha lejana en un incidente real en el entorno del consumidor, que es lo único que mueve prioridades.

// middleware/brownout.js — apagados breves programados de v1
const VENTANAS = [
  { desde: '2027-03-10T09:00:00Z', hasta: '2027-03-10T09:30:00Z' }, // 30 min
  { desde: '2027-04-14T09:00:00Z', hasta: '2027-04-14T11:00:00Z' }, // 2 h
  { desde: '2027-05-12T07:00:00Z', hasta: '2027-05-12T15:00:00Z' }, // 8 h
];

export function brownoutV1(req, res, siguiente) {
  const ahora = Date.now();
  const ventana = VENTANAS.find(v =>
    ahora >= Date.parse(v.desde) && ahora < Date.parse(v.hasta));

  if (!ventana) return siguiente();

  // Retry-After indica cuando vuelve v1: durante el brownout SI vuelve.
  res.set('Retry-After', String(Math.ceil((Date.parse(ventana.hasta) - ahora) / 1000)));
  res.status(410).json({
    error: {
      codigo: 'version_api_retirada',
      mensaje: 'Apagado programado de /v1. Migra a /v2 antes del 12/06/2027.',
      detalles: [{ campo: 'version', valor: 'v1', sucesor: '/v2' }],
    },
  });
}

Y el apagado definitivo, ya sin Retry-After:

GET /v1/cafes HTTP/1.1
Host: api.tiendaaroma.example

HTTP/1.1 410 Gone
Content-Type: application/json; charset=utf-8
Link: <https://api.tiendaaroma.example/v2/cafes>; rel="successor-version"

{
  "error": {
    "codigo": "version_api_retirada",
    "mensaje": "La version v1 se retiro el 12/06/2027. Usa /v2.",
    "detalles": [
      { "campo": "version", "valor": "v1" },
      { "campo": "guia", "valor": "https://docs.tiendaaroma.example/migracion-v2" }
    ]
  }
}

410 Gone y no 404: la diferencia comunica que el recurso existió y desapareció a propósito, y evita que un cliente crea que se ha equivocado de ruta.


  1. Retirar funcionalidades y el coste de lo que casi nadie usa

Año 2. El endpoint GET /v1/cafes/{id}/maridajes, añadido en el mes 4 por petición de marketing, recibe 40 peticiones al mes de un único cliente. Su coste no es cero:

  • Aparece en openapi.yaml, así que hay que documentarlo y revisarlo con Spectral.
  • Tiene pruebas que se ejecutan en cada CI y que a veces fallan por datos de prueba.
  • Tiene una tabla con su migración, que hay que arrastrar en cada cambio de esquema.
  • Bloquea decisiones: cualquier refactor del recurso Cafe debe contemplarlo.
  • Ocupa espacio mental en cada revisión de diseño.

La retirada de una funcionalidad concreta sigue el mismo protocolo que una versión, en pequeño: anuncio, Deprecation/Sunset sólo en esa ruta, contacto con el único consumidor, y 410. Duró tres meses. Lo que no se debe hacer nunca es borrarlo porque "casi nadie lo usa": ese "casi" es una empresa que depende de ello.


  1. Mantenimiento continuo: lo que nadie ve

Trabajo recurrente que no produce funcionalidades y sin el cual la API se degrada sola.

Dependencias y CVE. Auditoría semanal automatizada en el ci.yml de 05-04:

npm audit --audit-level=high            # falla el pipeline si hay alta o critica
npm outdated                            # informe semanal, no bloqueante
docker scout cves tiendaaroma-api:latest

La política acordada: vulnerabilidad crítica en dependencia explotable, parche en 48 h; alta, en 7 días; el resto, en la ventana mensual de mantenimiento.

Versión de Node. Node 20 entra en fin de soporte y hay que saltar a la siguiente LTS. Es un cambio invisible para el consumidor y peligroso para ti: se hace por canary (05-04), con el 5 % del tráfico durante 48 h, comparando p95 y tasa de error entre ambos grupos. Se apunta en el calendario antes de que el soporte expire, no después.

Rotación de secretos y claves de firma. Tres relojes distintos:

Secreto Rotación Cómo se rota sin cortar
Clave de firma JWT Cada 90 días Dos claves activas con kid; se firma con la nueva, se verifican ambas
Secreto HMAC de webhooks a RápidoEnvíos Cada 180 días Firma doble durante 14 días (Aroma-Firma y Aroma-Firma-Siguiente)
Credenciales de OAuth de CataBox Anual o ante incidente Solapamiento de 30 días

Revisión de SLO. Cada semestre. Si el SLO del 99,9 % nunca se ha rozado en un año, o está mal medido o es demasiado laxo. Si se incumple cada mes, o no es alcanzable con la arquitectura actual, o el presupuesto de error se está usando como excusa. En Tienda Aroma, el SLO de latencia de /cafes se endureció de 400 ms a 300 ms en el año 2 tras el trabajo de índices.

Revisión de seguridad. Repaso anual del OWASP API Top 10 (04-01) contra el estado real: autorización a nivel de objeto en cada endpoint nuevo, límites de consumo, exposición de datos. Se documenta como cualquier revisión de diseño.

El coste de infraestructura como señal de diseño. La factura de la API es una métrica de diseño disfrazada de métrica financiera. Si un endpoint cuesta desproporcionadamente, casi siempre hay una decisión de contrato detrás: GET /v1/pedidos sin paginación obligatoria devolviendo miles de elementos, ausencia de ETag en un recurso muy consultado, o un cliente haciendo polling donde debería haber un webhook. Antes de escalar la infraestructura, revisa el contrato: sale más barato.


  1. Inventario de APIs y APIs zombis

En 04-02 definimos la propiedad de cada API y en 05-06 montamos el portal de desarrollador con su inventario. Su utilidad real aparece justo ahora: en la migración a v2 apareció un consumidor llamado integracion-erp que nadie recordaba haber autorizado. Existía, funcionaba y estaba en producción.

Una API zombi es una API (o una versión, o un endpoint) que sigue respondiendo, consume recursos y presenta superficie de ataque, pero no tiene dueño identificable. Se detectan cruzando tres fuentes: el inventario declarado, las métricas reales de tráfico y el registro de credenciales emitidas. Cualquier fila que aparezca en una fuente y falte en otra es una alarma.

Cada entrada del inventario debe responder, como mínimo: quién la mantiene, quién la consume, qué versión está vigente, qué fecha de retirada tiene si está deprecada, y dónde está su openapi.yaml. Sin dueño no se despliega: es la única forma de que el inventario no envejezca.


  1. Gobierno con varios equipos

Año 3. Ya no hay un equipo, hay tres: catálogo, pedidos y suscripciones. Sin gobierno, en seis meses tienes tres APIs que parecen de tres empresas distintas: una con snake_case, otra con errores en texto plano, otra paginando con page/size.

El gobierno de Tienda Aroma se apoya en cuatro piezas, todas ya conocidas:

  • La guía de estilo (02-01) como norma escrita, no como recomendación. Es la referencia que zanja discusiones en una revisión.
  • La revisión de diseño: antes de escribir código, el equipo presenta el fragmento de openapi.yaml propuesto. Diez minutos con dos personas de otros equipos evitan meses de deuda. Se revisa el contrato, no la implementación.
  • Puertas automáticas en CI (05-04/05-05): Spectral valida el estilo y oasdiff detecta cambios rompedores. Lo automático no se discute, y eso es precisamente su virtud.
  • ADR en docs/decisiones/: cada decisión estructural con su contexto, alternativas y consecuencias. Sirve para que dentro de dos años nadie "arregle" algo que estaba así a propósito.
# .github/workflows/ci.yml (extracto) — puertas de contrato
  contrato:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }

      - name: Estilo del contrato (guia de estilo como codigo)
        run: npx @stoplight/spectral-cli lint openapi.yaml --fail-severity=warn

      - name: Cambios rompedores frente a la version publicada
        run: |
          git show origin/master:openapi.yaml > /tmp/openapi-publicado.yaml
          npx oasdiff breaking /tmp/openapi-publicado.yaml openapi.yaml --fail-on ERR

      # Si el cambio ES rompedor a proposito, se aprueba con la etiqueta
      # "cambio-rompedor-aprobado" en el PR y un ADR enlazado. Nunca en silencio.

Y el flujo humano completo de un cambio de contrato:

sequenceDiagram
    participant E as Equipo
    participant R as Revision de diseno
    participant CI as CI (Spectral + oasdiff)
    participant C as Consumidores
    E->>R: Propuesta de cambio en openapi.yaml
    R-->>E: Guia de estilo, ADR si es estructural
    E->>CI: Pull request
    CI-->>E: Rompedor detectado
    alt No rompedor
        CI->>C: Despliegue canary + changelog
    else Rompedor
        E->>R: ADR con alternativas
        R-->>E: A la deuda de contrato o a v2
    end
    C-->>E: Metricas de uso y soporte

Errores Comunes y Consejos

  • Depurar en caliente en lugar de mitigar. Cada minuto de diagnóstico durante un S1 se paga con presupuesto de error. Revierte, apaga la flag, y luego investiga.
  • Post-mortems que terminan en "falta de atención". Si la acción no se puede cerrar con un enlace verificable, no es una acción. Y si el documento nombra a una persona como causa, la próxima vez nadie contará lo que pasó.
  • Confundir "sin culpables" con "sin consecuencias". Las acciones tienen responsable y plazo, y se revisan en la retrospectiva del mes siguiente.
  • Creer que añadir un campo nunca rompe. Sólo es cierto con clientes tolerant readers. Publica ese requisito en la guía de integración desde el día uno.
  • Endurecer validaciones sin fase de aviso. Es el cambio rompedor que más se cuela porque no toca el esquema visible. Mide primero, rechaza después.
  • Etiquetar métricas por ruta concreta o por id. La cardinalidad estalla y te quedas sin observabilidad justo cuando la necesitas. Plantillas de ruta y conjuntos cerrados.
  • Apagar v1 por calendario sin mirar métricas por versión. Siempre aparece un consumidor olvidado. Las métricas mandan sobre el calendario; el Sunset anunciado no se adelanta, pero sí se puede retrasar.
  • Lanzar v2 por incomodidad interna. Si el dolor lo sufre sólo tu equipo, resuélvelo por dentro. Una versión nueva es una factura que pagan todos tus consumidores.
  • Tratar los 4xx como culpa del cliente. Son tu lista de tareas de documentación ordenada por impacto.
  • Consejo final: publica el calendario de deprecación en un sitio estable y respétalo aunque duela. Un Sunset que se adelanta destruye más confianza que un incidente de 34 minutos.

Ejercicios

Ejercicio 1: clasificar cambios

Para cada cambio propuesto sobre v1 de Tienda Aroma, indica si es retrocompatible, si es rompedor, y cuál sería la estrategia correcta:

  1. Añadir el campo origenCertificado (booleano) a la representación de Cafe.
  2. Añadir el valor filtro al enumerado tueste en las respuestas.
  3. Cambiar el limite por defecto de GET /v1/cafes de 20 a 50.
  4. Empezar a rechazar precioMin negativo con 422 datos_invalidos.
  5. Añadir el filtro opcional ?certificado=true.

Ejercicio 2: decidir sobre la v2

Una API interna de facturación acumula estas deudas: (a) el campo total está en euros como número decimal y provoca errores de redondeo; (b) GET /facturas no pagina y devuelve hasta 4.000 elementos; (c) el nombre factura_id usa snake_case mientras el resto de la API usa camelCase. Sólo hay dos consumidores, ambos internos. ¿Lanzarías una v2? Justifica con criterios, no con gustos.

Ejercicio 3: acciones de un post-mortem

Un despliegue del viernes por la tarde introdujo un fallo en la renovación de tokens: durante 18 minutos, todas las peticiones de Aroma Móvil recibieron 401 no_autenticado. Se detectó porque un usuario escribió en redes sociales; nadie del equipo vio ninguna alerta. Escribe cinco acciones para el post-mortem, cada una con tipo (correctiva, detección, preventiva, organizativa, comunicación) y un criterio de cierre verificable.


Soluciones

Ejercicio 1

# Cambio Veredicto Estrategia
1 origenCertificado Retrocompatible* Añadir opcional y documentar. El asterisco: si algún consumidor valida con additionalProperties: false, sí rompe. Comprobar antes en la guía de integración y avisar en el changelog
2 Valor filtro en respuestas Rompedor en la práctica Cualquier cliente con un switch exhaustivo sobre tueste fallará. Anunciar con 30 días, documentar el valor nuevo, y primero aceptarlo en peticiones (seguro) antes de emitirlo en respuestas
3 limite por defecto 20 → 50 Rompedor Cambia el tamaño de página que el cliente recibe sin pedirlo; puede desbordar interfaces y multiplicar la carga. No cambiarlo en v1. Alternativa: documentar mejor limite y dejar el valor por defecto en v2
4 Rechazar precioMin negativo Rompedor Aunque sea "más correcto", peticiones antes aceptadas empiezan a fallar. Fase de aviso midiendo con superRefine durante 6 semanas; si el contador llega a cero, rechazar. Y 400 parametro_invalido, que es un parámetro de consulta, no un cuerpo
5 Filtro ?certificado=true Retrocompatible Opcional; ausente = comportamiento actual. Añadirlo a openapi.yaml, con su índice en la base de datos (lección del INC-041)

Ejercicio 2

No, todavía no. Análisis por deuda:

  • (a) total decimal en euros. Es real y grave (redondeo en dinero), pero se resuelve añadiendo: publicar totalCentimos como campo nuevo, documentarlo como preferente, deprecar total en la documentación y medir su uso. No requiere versión.
  • (b) GET /facturas sin paginación. Se puede paginar de forma retrocompatible: aceptar limite/desplazamiento, y mientras no se envíen, devolver el comportamiento actual. Con dos consumidores internos, se puede negociar además un límite máximo con aviso previo. No requiere versión.
  • (c) factura_id en snake_case. Es incomodidad estética. Solución: emitir también facturaId (ambos campos conviven), documentar el nuevo, y eliminar el viejo cuando las métricas de uso lo permitan o llegue una v2 motivada por otra cosa.

Además, con dos consumidores internos el coste de coordinación es bajísimo comparado con el de mantener dos versiones. Criterio aplicable: ninguna de las tres deudas obliga a romper, luego no hay v2. Lo que sí procede es abrir tres entradas en el registro de deuda de contrato con candidata_v2: true y revisarlas cada trimestre.

Ejercicio 3

# Acción Tipo Criterio de cierre
1 Corregir el fallo de renovación de tokens y desplegar con canary al 5 % Correctiva Commit enlazado + 24 h de canary con tasa de 401 en línea base
2 Alerta sobre tasa de 401 por cliente, disparo a los 3 min por encima del doble de la base Detección Alerta creada y probada con una inyección controlada en preproducción
3 Prueba de integración del ciclo completo de renovación (token caducado → refresco → petición) en CI Preventiva Prueba en node:test + Supertest ejecutándose en ci.yml y fallando si se revierte el arreglo
4 Política de congelación de despliegues los viernes a partir de las 15:00 salvo correcciones urgentes Organizativa Regla publicada en el repositorio y comprobación automática en el pipeline
5 Nota en la página de estado y aviso a los usuarios afectados de Aroma Móvil con resumen del incidente Comunicación Entrada publicada con enlace al post-mortem

Observa que la acción 2 es la más valiosa: el fallo duró 18 minutos, pero el problema real es que la detección llegó desde fuera. Un incidente que descubre un usuario en redes sociales es, ante todo, un fallo de observabilidad.


Conclusión

Diseñar una API es un ejercicio acotado; mantenerla es un compromiso indefinido. En esta lección hemos recorrido tres años de la API de Tienda Aroma y hemos visto que casi todo el trabajo posterior al lanzamiento consiste en proteger a quien ya confía en ti: vigilar las señales adecuadas desde el primer día, mitigar antes de diagnosticar cuando algo se rompe, escribir post-mortems que arreglen el sistema en vez de buscar responsables, escuchar lo que los 4xx y la analítica de uso te están diciendo, anotar la deuda de contrato en lugar de fingir que no existe, agotar todo lo que se puede añadir sin romper, y —sólo cuando ya no queda alternativa— planificar una v2 con su calendario, sus cabeceras Deprecation y Sunset, sus brownouts y su 410 final. A eso se suma el mantenimiento que nadie aplaude: CVE, versiones de Node, rotación de claves, revisión de SLO, inventario sin zombis y un gobierno con Spectral, oasdiff y ADR que mantenga la coherencia cuando ya no hay un solo equipo.

La conclusión de fondo es sencilla y exigente a la vez: una API es un compromiso a largo plazo con quien la consume. Cada campo publicado es una promesa, cada Sunset anunciado es un contrato, y la confianza que construyes durante tres años puede perderse con un cambio "inocuo" desplegado un viernes por la tarde. La calidad de una API no se mide el día del lanzamiento, sino en la facilidad con la que un integrador sigue trabajando con ella cuando ya nadie del equipo original queda en la empresa.

Con esto cerramos el recorrido de casos prácticos y análisis. Te toca a ti: en 06-04, Proyecto final: Diseñar y desarrollar tu propia API RESTful, aplicarás por fases todo lo aprendido —diseño del contrato, implementación, seguridad, documentación y despliegue— sobre un dominio propio, con una rúbrica clara para autoevaluarte. Todo lo que hemos hecho hasta ahora estaba preparando ese encargo.

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