Ilustración editorial: Caché HTTP para APIs públicas pequeñas: ETags, peticiones condicionales y Cache-Control sin pisar minas

rendimiento de apis · cache http · etag · cache-control · peticiones condicionales · rest api · diseno de apis

Caché HTTP para APIs públicas pequeñas: ETags, peticiones condicionales y Cache-Control sin pisar minas

Guía práctica y opinada para implementadores independientes: cómo añadir ETags, If-None-Match, If-Match y Cache-Control en una API REST pública pequeña, con sus renuncias y trampas.

Publicado:

Lo que realmente necesitas para cachear una API pública pequeña

Si mantienes una API REST pública pequeña y tu origen repite el mismo trabajo una y otra vez para los mismos clientes, no necesitas una base de datos nueva ni una cola. Necesitas tres mecanismos HTTP bien aplicados: una política de Cache-Control sensata, un ETag por recurso y If-None-Match (más If-Match para escrituras). Este artículo recorre cómo encajan esas piezas, dónde rinde cada una y los modos de fallo concretos que yo evitaría poner en producción.

Voy a mantenerlo aterrizado. Sin benchmarks inventados, sin promesas de que el caché te va a recortar la factura a la mitad. La ventaja es real, pero está acotada por la forma de tu tráfico; el coste de hacerlo mal es servir datos caducados o romper escrituras.

Las tres capas, en palabras claras

Piensa en el caché HTTP como tres capas concéntricas. Elige la que encaje con el perfil de riesgo de cada endpoint; no apliques una única regla a toda tu API.

  1. Ventana de frescuraCache-Control: max-age=N. Se le dice a la clientela que la respuesta vale N segundos y que no debe revalidar durante esa ventana.
  2. ValidaciónETag con If-None-Match. Pasada la ventana, la clientela pregunta al servidor si el recurso cambió. Si no, el servidor responde 304 Not Modified sin cuerpo.
  3. Seguridad de escrituraIf-Match con el ETag que la clientela recibió en su última lectura. El servidor solo aplica un PUT o PATCH si el ETag de la clientela sigue coincidiendo con el actual. Evita actualizaciones perdidas cuando dos clientes editan el mismo registro.

Estas capas se componen. Puedes fijar max-age y aun así validar después de la ventana, y puedes exigir If-Match en cada escritura sin enviar jamás un max-age en la respuesta.

Configurar Cache-Control correctamente para una API

Cache-Control es la cabecera más decisiva que envías y, a la vez, la que más se suele configurar mal. Las directivas que usarás de verdad, tomadas del conjunto estándar documentado en MDN, son public, private, no-store, no-cache, max-age, s-maxage, must-revalidate y stale-while-revalidate.

Un conjunto de reglas que sigo:

Un patrón habitual para recursos públicos de solo lectura:

Cache-Control: public, max-age=60, must-revalidate
ETag: "a1f0c2"

Un patrón habitual para recursos por usuario:

Cache-Control: no-store

Un patrón habitual para recursos que se leen pero hay que validar y cambian de forma impredecible:

Cache-Control: no-cache
ETag: "a1f0c2"

Generar ETags que puedas defender

Un ETag es solo una cadena que el servidor entrega a la clientela como “huella” de la representación actual. Los dos sabores que distingue la especificación son los ETag fuertes y débiles.

Para APIs JSON, un ETag débil suele ser la elección correcta. Es poco probable que el formato de tu respuesta sea byte-estable entre despliegues, y un hash sobre el JSON canónico es un validador débil perfectamente válido.

Formas habituales de generar el valor:

  1. Un hash del cuerpo de la respuesta. Sencillo y correcto, pero debes hashear la forma canónica, no lo que tu serializador haya decidido emitir ese día. Si cambias el serializador y los bytes cambian, el ETag cambia y se invalidan todos los cachés. Es molesto, pero no inseguro.
  2. Un número de versión que incrementas al escribir. Sube un contador en la fila de la base de datos cada vez que se actualiza. El ETag es el contador. Es barato y estable, pero solo si todos los caminos de código que mutan la fila pasan por el mismo punto de actualización.
  3. Un valor derivado de Last-Modified. Si ya guardas una marca temporal, puedes usarla. Es más débil que un hash y tiene ambigüedad a nivel de segundo, pero basta para muchas APIs públicas.

Lo que yo no haría: derivar el ETag de un hash de la fila completa, incluidos campos que la clientela no ve, y devolver luego solo un subconjunto de esos campos. El ETag y la representación tienen que describir los mismos bytes, o le acabaremos diciendo a la clientela que dos respuestas distintas son equivalentes.

GETs condicionales con If-None-Match

El flujo de lectura es el primero que implementarás, porque es la victoria más barata.

  1. La clientela envía GET /widgets/42. Sin cabeceras condicionales.
  2. El servidor responde 200 OK con el cuerpo y un ETag: "a1f0c2".
  3. La clientela guarda ambos. Más tarde, envía GET /widgets/42 con If-None-Match: "a1f0c2".
  4. El servidor compara el ETag entrante con el actual. Si coinciden, devuelve 304 Not Modified sin cuerpo y con el mismo ETag. Si no, devuelve 200 OK con el cuerpo nuevo y el ETag nuevo.

El ahorro de ancho de banda en un 304 es real: no hay cuerpo, solo cabeceras. El ahorro de latencia depende de cuánto del trabajo en el origen puedas saltarte. Como mínimo, un 304 te permite no serializar la respuesta y no enviarla por la red. Una implementación más agresiva puede atajar antes de tocar la base de datos guardando los ETags en memoria indexados por identificador de recurso.

Los frameworks ayudan. Flask, Express y FastAPI tienen soporte de primera clase o mantenido por la comunidad para ETags. Express, por ejemplo, puede autogenerar un ETag débil a partir del cuerpo de la respuesta. Para una API pequeña, yo me apoyaría en eso y lo sobrescribiría donde el valor por defecto no encaje.

Hay una cabecera condicional relacionada, If-Modified-Since, que toma una marca temporal en vez de un ETag. Es más antigua, más tosca y sigue siendo útil cuando genuinamente no quieres calcular un hash. Si envías a la vez ETag y Last-Modified en la misma respuesta, las clientes preferirán If-None-Match y está bien; las dos no entran en conflicto.

Escrituras condicionales con If-Match

El flujo de escritura es el que no deberías saltarte, porque es el que evita pérdida de datos.

  1. La clientela lee /orders/77 y recibe ETag: "b9c1".
  2. La clientela envía PATCH /orders/77 con If-Match: "b9c1" y el cuerpo parcial.
  3. El servidor compara el ETag entrante con el actual.
    • Coincide: aplica el parche, devuelve 200 OK con la nueva representación y un ETag nuevo.
    • No coincide: devuelve 412 Precondition Failed sin cambios en el cuerpo. La clientela tiene que re-leer, fusionar y reintentar.

Este es el patrón estándar de control de concurrencia optimista. El servidor no necesita mantener bloqueos; simplemente rechaza la escritura cuando la visión del cliente está caducada. If-Match es la cabecera correcta para PUT y PATCH. If-None-Match: * es una variante útil para PUT sobre un recurso que todavía no debe existir, por ejemplo, crear un registro con un identificador elegido por la clientela.

Si solo implementas una pieza de este artículo, implementa If-Match en las escrituras. Cuesta casi nada y es la diferencia entre una API que pierde ediciones y una API que le dice a la clientela “alguien más cambió esto, refresca y vuelve a intentarlo”.

Cuándo merece la pena cachear en cliente

Un marco honesto: cachea la respuesta cuando el coste de servirla actualizada sea mayor que el coste de equivocarse a veces.

Una nota sobre stale-while-revalidate. Le dice al caché que puede servir la copia caducada mientras trae una nueva en segundo plano. Para una API pública pequeña, es una forma barata de hacer que la primera petición tras la caducidad se sienta instantánea. La renuncia es que la segunda petición tras la caducidad puede seguir viendo el valor antiguo. Para la mayoría de lecturas públicas, eso es aceptable. Para cualquier cosa donde la frescura sea el producto, no lo es.

Pasos concretos de implementación

Una lista corta para recorrer, en orden, al añadir caché a una API pequeña ya existente.

  1. Inventaría tus endpoints. Marca cada uno como público-compartido, público-validar o por-usuario.
  2. Para los endpoints por usuario, pon Cache-Control: no-store y sigue. Es la victoria más fácil porque es la que evita fugas a través de cachés compartidos.
  3. Para los endpoints público-compartidos, escoge un max-age a partir de la frecuencia con la que cambian los datos de verdad, y añade must-revalidate para que un origen roto no alargue la ventana en silencio.
  4. Para los endpoints público-validar, pon Cache-Control: no-cache y genera un ETag débil para cada representación.
  5. En cada lectura, incluye tanto ETag como Last-Modified. Son baratos de añadir y dejan a las clientelas elegir.
  6. Atiende If-None-Match e If-Modified-Since en el camino de lectura. Devuelve 304 con los mismos validadores y sin cuerpo cuando coincidan.
  7. En cada escritura (PUT, PATCH, DELETE), exige If-Match con el ETag que la clientela vio por última vez. Devuelve 412 Precondition Failed cuando no coincida.
  8. Logea los 304 y los 412 aparte de los 200. La proporción te dice si la capa de caché está haciendo trabajo útil o solo añadiendo complejidad.
  9. Escribe tests para los caminos tristes: ETag ausente, ETag caducado, validador débil/fuerte que no encaja, If-Match sobre un recurso que no existe. Son los bugs que aparecen en producción y nunca en desarrollo.

Preguntas frecuentes

¿Necesito tanto ETag como Last-Modified? No, pero enviar ambos es barato y permite a las clientelas usar la que prefieran. Si tienes que elegir, quédate con ETag; no es ambiguo.

¿El ETag debe ser un hash de la fila completa o solo del cuerpo de la respuesta? Del cuerpo de la respuesta, en su forma canónica. Si hasheas la fila y la respuesta incluye un campo derivado, los dos pueden divergir.

¿no-cache es lo mismo que no-store? No. no-store prohíbe almacenar la respuesta. no-cache permite almacenarla pero exige revalidar en cada uso. Son directivas muy distintas y se confunden con frecuencia.

¿Qué significa el prefijo W/ en un ETag? Marca un validador débil. Dos respuestas con el mismo ETag débil son semánticamente equivalentes pero no necesariamente idénticas a nivel de bytes. Para APIs JSON esto es casi siempre lo que quieres.

¿Puedo usar If-Match en un POST? En general no, porque un POST crea un recurso que la clientela aún no ha visto. La forma If-None-Match: * es la relevante para “crear solo si esto no existe todavía”.

¿Cuánto se ahorra realmente con un 304? Nada de cuerpo, nada de coste de serialización, y la clientela reutiliza la representación en caché. La ganancia es ancho de banda en respuestas grandes y algo de CPU en el origen. La cifra depende del tamaño de tus respuestas y de tu backend; yo no prometería un porcentaje concreto sin medir tu propio tráfico.

Cierre

El caché para una API pública pequeña no es un proyecto de rendimiento. Es un proyecto de diseño. Escoge la política de Cache-Control adecuada por endpoint, reparte ETags débiles, valida lecturas con If-None-Match y protege escrituras con If-Match. Esos cuatro hábitos juntos bastan para la mayoría de APIs públicas y cuestan menos de mantener que la alternativa, que es una capa de Redis y una charla en un meetup sobre invalidación de cachés.

Fuentes