Un martes por la mañana, la API de Tienda Aroma empieza a responder en cuatro segundos. No hay despliegue nuevo, no hay error en los logs, la base de datos no está caída. Mirando el tráfico aparece el culpable: una única dirección IP haciendo 400 peticiones por segundo a GET /v1/cafes?limite=100, recorriendo el catálogo entero cada pocos segundos. No es un ataque sofisticado: es alguien copiando el catálogo, o —igual de probable— un cliente con un useEffect mal escrito que reintenta en bucle sin esperar.

La API está autenticada, autorizada y endurecida. Y aun así, cualquiera puede tumbarla simplemente llamándola mucho. Esta lección cierra la última fila abierta del modelo de amenazas de 04-02: API4, consumo ilimitado de recursos. Veremos por qué toda API pública necesita límites, los cuatro algoritmos clásicos con sus compromisos, qué se usa como clave de agrupación, la respuesta 429 con sus cabeceras, la implementación en el proyecto con express-rate-limit y Redis, las demás defensas de disponibilidad, y qué debe hacer un cliente bien educado cuando le dicen que pare.

Advertencia. El rate limiting es una defensa de disponibilidad y, mal ajustado, puede negar el servicio a usuarios legítimos o dejar pasar un ataque. Los valores de esta lección son un punto de partida razonable para un caso ficticio; un despliegue real debe dimensionarlos con datos propios y revisarlos con un profesional de seguridad.

Contenido

  1. Por qué toda API pública necesita límites
  2. Rate limiting, throttling y cuotas
  3. Los cuatro algoritmos
  4. Implementación comentada del cubo de fichas
  5. La clave: contra qué se cuenta
  6. El problema de la IP: NAT, proxies y trust proxy
  7. Límites por endpoint
  8. Los niveles de Tienda Aroma
  9. La respuesta 429 y sus cabeceras
  10. Implementación: src/middleware/limite-peticiones.js
  11. Almacén en memoria frente a Redis
  12. Otras defensas de disponibilidad
  13. 503, Retry-After y servicio_no_disponible
  14. Qué hace un cliente bien educado
  15. Cómo se comunican los límites en la documentación
  16. Dónde vive el rate limiting en producción
  17. Pruebas del límite con node:test

  1. Por qué toda API pública necesita límites

Los motivos son cinco y conviene distinguirlos, porque cada uno pide un límite distinto:

Motivo Ejemplo en Tienda Aroma Qué límite lo ataja
Abuso deliberado Fuerza bruta contra POST /v1/sesiones Muy estricto por IP y por correo
Scraping Copiar el catálogo entero cada hora Moderado por IP en lectura
Clientes mal programados Un bucle de reintentos sin espera Moderado, con Retry-After claro
Coste Búsquedas caras que disparan la factura Límite específico en ?q=
Equidad Un consumidor consume el 95 % de la capacidad Límite por consumidor autenticado

El tercero merece un matiz importante: la mayoría del tráfico abusivo no es malicioso. Es un desarrollador que no ha visto que su reintento no tiene espera, una app móvil que refresca en cada scroll, un cron que se solapa consigo mismo. Eso cambia el diseño de la respuesta: el 429 tiene que ser pedagógico, decir cuándo volver y ser fácil de manejar programáticamente, porque su destinatario habitual es alguien que quiere arreglarlo.

Y hay un motivo transversal que los engloba: proteger la base de datos. Tu API puede escalar a más instancias; el SQLite de Tienda Aroma, o el PostgreSQL que venga después, no tanto. El rate limiting es lo que impide que un pico de tráfico se traduzca en una avalancha de consultas que deja al resto de consumidores sin servicio.

  1. Rate limiting, throttling y cuotas

Tres términos que se usan como sinónimos y no lo son:

Concepto Qué hace Horizonte Respuesta típica
Rate limiting Rechaza lo que exceda una tasa Segundos o minutos 429 inmediato
Throttling Retrasa en vez de rechazar Segundos 200 más lento, o cola
Cuota Total permitido en un periodo largo Día o mes 429 o 402 al agotarse
  • Rate limiting: «100 peticiones por minuto». La 101 se rechaza.
  • Throttling: «como máximo 10 peticiones concurrentes». La 11 espera en cola. Es más amable con el cliente pero consume recursos tuyos mientras espera, y puede degradar todo el sistema si la cola crece: por eso siempre necesita un tope de cola y un timeout.
  • Cuota: «50.000 peticiones al mes en el plan gratuito». Es un concepto comercial más que técnico; se muestra en la factura, no en cada respuesta.

Tienda Aroma usa rate limiting como mecanismo principal, con una cuota diaria para el socio RápidoEnvíos. El throttling aparece de forma natural en un sitio: el pool de conexiones a la base de datos, que ya limita la concurrencia real.

  1. Los cuatro algoritmos

Ventana fija

Se cuenta cuántas peticiones hay en el minuto actual; al cambiar de minuto, el contador vuelve a cero.

Minuto 10:00 → contador 0..100
Minuto 10:01 → contador vuelve a 0

Es trivial de implementar y muy barato: un entero por clave. Su problema es el efecto de borde:

10:00:59 → 100 peticiones  (permitidas: la ventana de las 10:00 estaba a 0)
10:01:00 → 100 peticiones  (permitidas: ventana nueva)
────────────────────────────
200 peticiones en 1 segundo con un límite de "100 por minuto"

El doble del límite en un instante. Con límites generosos es asumible; para proteger un login, no.

Ventana deslizante con registro (sliding window log)

Se guarda la marca de tiempo de cada petición y se cuentan las de los últimos 60 segundos, exactamente. Es preciso al 100 % y no tiene efecto de borde. El coste es la memoria: con un límite de 1.000 por minuto y 50.000 claves activas, son 50 millones de marcas de tiempo.

Ventana deslizante con contador (sliding window counter)

El compromiso práctico. Se guardan dos contadores —el de la ventana actual y el de la anterior— y se estima el valor deslizante por interpolación:

// A los 15 s de la ventana actual, se ha consumido el 25 % de ella,
// así que aún pesa el 75 % de la ventana anterior.
const pesoAnterior = 1 - transcurridoEnVentana / duracionVentana;   // 0,75
const estimado = contadorAnterior * pesoAnterior + contadorActual;

Con dos números por clave se elimina prácticamente el efecto de borde. Es lo que usan la mayoría de las implementaciones serias, incluida Cloudflare.

Cubo de fichas (token bucket)

Un cubo con capacidad C que se rellena a R fichas por segundo. Cada petición consume una ficha; si no hay, se rechaza.

Su gracia es que permite ráfagas de forma controlada: un cliente que ha estado callado acumula fichas hasta C y puede gastarlas de golpe, pero su tasa sostenida nunca supera R. Eso encaja muy bien con el tráfico real de una API: una app que abre una pantalla hace ocho peticiones seguidas y luego calla un minuto, y no queremos castigarla.

Cubo con fugas (leaky bucket)

Las peticiones entran en una cola que se vacía a ritmo constante. Suaviza el tráfico de salida por completo, pero no permite ráfagas y añade latencia: es throttling, no rate limiting. Se usa más para regular la salida hacia un sistema aguas abajo (por ejemplo, las llamadas a la pasarela de pago) que para la entrada.

Comparativa

Algoritmo Precisión Memoria por clave Complejidad Ráfagas Cuándo usarlo
Ventana fija Baja (2× en el borde) 1 entero Muy baja Descontroladas en el borde Límites generosos, prototipos
Ventana deslizante con log Perfecta N marcas de tiempo Media No Endpoints críticos con pocas claves
Ventana deslizante con contador Muy alta 2 enteros Media Casi ninguna Uso general
Cubo de fichas Alta 2 números Media Sí, acotadas APIs con tráfico a ráfagas
Cubo con fugas Alta Cola Alta No (añade latencia) Regular la salida hacia terceros

La elección de Tienda Aroma: cubo de fichas para el límite general —porque el tráfico de la SPA y de Aroma Móvil es naturalmente a ráfagas— y ventana deslizante estricta para POST /v1/sesiones, donde no queremos ninguna ráfaga.

  1. Implementación comentada del cubo de fichas

// src/middleware/cubo-fichas.js  (didáctico: en producción usamos express-rate-limit)

/**
 * Cubo de fichas.
 *
 * @param {number} capacidad  Fichas máximas acumulables = tamaño de ráfaga permitido.
 * @param {number} tasa       Fichas que se reponen por segundo = tasa sostenida.
 */
export function crearCubo(capacidad, tasa) {
  // Se guarda por clave: { fichas, ultimaRecarga }.
  // El truco central: NO hay temporizador. Las fichas se calculan cuando se
  // consultan, a partir del tiempo transcurrido. Eso hace el algoritmo O(1)
  // y evita mantener miles de intervalos activos.
  const cubos = new Map();

  function consumir(clave, coste = 1) {
    const ahora = Date.now();
    const cubo = cubos.get(clave) ?? { fichas: capacidad, ultimaRecarga: ahora };

    // 1. Recarga perezosa: fichas ganadas desde la última consulta.
    const segundos = (ahora - cubo.ultimaRecarga) / 1000;
    cubo.fichas = Math.min(capacidad, cubo.fichas + segundos * tasa);
    cubo.ultimaRecarga = ahora;

    // 2. ¿Alcanza para esta petición?
    const permitida = cubo.fichas >= coste;
    if (permitida) cubo.fichas -= coste;

    cubos.set(clave, cubo);

    // 3. Cuándo habrá fichas suficientes, en segundos (para Retry-After).
    const faltan = Math.max(0, coste - cubo.fichas);
    const esperaSegundos = permitida ? 0 : Math.ceil(faltan / tasa);

    return {
      permitida,
      restantes: Math.floor(cubo.fichas),
      esperaSegundos,
    };
  }

  // 4. Limpieza: sin esto, la memoria crece sin límite con cada IP nueva.
  //    Un cubo lleno es indistinguible de uno que no existe, así que se puede tirar.
  function limpiar() {
    const ahora = Date.now();
    const segundosParaLlenar = capacidad / tasa;
    for (const [clave, cubo] of cubos) {
      if ((ahora - cubo.ultimaRecarga) / 1000 > segundosParaLlenar) cubos.delete(clave);
    }
  }
  const temporizador = setInterval(limpiar, 60_000);
  temporizador.unref();   // no impide que el proceso termine

  return { consumir, limpiar };
}

Cuatro detalles que hacen que esto funcione y que casi siempre se hacen mal:

La recarga perezosa (paso 1) es la idea clave. Un cubo por cliente con un setInterval cada uno sería inviable con miles de claves; calcular las fichas al consultarlas da el mismo resultado con coste constante.

Math.min(capacidad, ...) impide que un cliente inactivo durante un día acumule 86.400 fichas y descargue la base de datos entera de golpe. El máximo acumulable es la capacidad, y por eso la capacidad es el tamaño de ráfaga permitido.

El coste parametrizable (paso 2) abre la puerta al coste por consulta del apartado 12: una búsqueda cara puede consumir cinco fichas en vez de una.

La limpieza (paso 4) es un requisito de disponibilidad, no una optimización: sin ella, un atacante que rote direcciones IP hace crecer el Map hasta agotar la memoria del proceso. Y unref() evita que el temporizador mantenga vivo el proceso al apagarlo, algo que rompería el apagado ordenado de 03-07 y las pruebas de 03-08.

  1. La clave: contra qué se cuenta

Un límite siempre se aplica a un grupo. Elegir mal la clave es el error más caro:

Clave Ventajas Inconvenientes Uso en Tienda Aroma
IP Funciona sin autenticación NAT agrupa a miles; IPv6 permite rotar; proxies Tráfico anónimo y login
Usuario autenticado (sub) Justo y preciso Solo tras autenticar Tráfico autenticado
client_id de OAuth Aísla CataBox de la SPA Solo con OAuth (04-03) Terceros
Clave de API Igual, para socios Hay que emitirlas y rotarlas RápidoEnvíos
Combinación Lo mejor de cada una Más estado Lo que usamos

La regla de selección de Tienda Aroma, en orden de preferencia:

// src/middleware/limite-peticiones.js  (fragmento)
export function claveDeLimite(req) {
  // 1. Aplicación de terceros identificada: se le limita a ella, no al usuario.
  if (req.usuario?.clienteOauth) return `oauth:${req.usuario.clienteOauth}:${req.usuario.id}`;
  // 2. Usuario autenticado: la clave más justa.
  if (req.usuario?.id) return `usr:${req.usuario.id}`;
  // 3. Anónimo: no queda más remedio que la IP.
  return `ip:${req.ip}`;
}

El caso 1 merece explicación: si CataBox tiene un bug y machaca la API en nombre de 500 usuarios, limitar por usuario no detiene nada —son 500 claves distintas—. La clave compuesta permite aplicar además un tope agregado por client_id, que es lo que realmente protege.

  1. El problema de la IP: NAT, proxies y trust proxy

Limitar por IP tiene tres problemas serios que hay que conocer antes de fijar un número:

NAT. Una oficina, una universidad o un operador móvil pueden presentar cientos o miles de usuarios detrás de una única IP pública. Un límite de 60 por minuto por IP deja sin servicio a toda una empresa cuyos empleados usen la tienda. Por eso el límite anónimo debe ser generoso, y el estricto reservarse para lo autenticado o para operaciones concretas.

IPv6. Un atacante con un prefijo /64 dispone de billones de direcciones. Limitar por IPv6 individual es inútil: hay que agrupar por prefijo (habitualmente /64).

Proxies. Este es el que rompe el código. Si tu API está detrás de un balanceador o una CDN —y en producción lo estará—, req.ip es la IP del proxy, no la del cliente. Todo el tráfico comparte clave y el primer usuario agota el límite de todos.

La IP real llega en X-Forwarded-For, una cabecera con la cadena de saltos:

X-Forwarded-For: 203.0.113.9, 70.41.3.18, 150.172.238.178
                 ↑ cliente     ↑ proxy 1   ↑ proxy 2

Y aquí está la trampa: esa cabecera la puede escribir cualquiera. Un atacante manda X-Forwarded-For: 1.2.3.4 y, si confías ciegamente, cambia de identidad en cada petición y anula el límite.

// src/app.js
// ❌ PELIGROSO: confía en toda la cadena, incluida la parte que escribió el cliente.
app.set('trust proxy', true);

// ✅ Confía exactamente en 1 salto: el balanceador que TÚ controlas.
//    Express toma entonces la penúltima entrada de X-Forwarded-For, que es la que
//    escribió tu propio proxy y no puede falsificar el cliente.
app.set('trust proxy', 1);

// ✅ Alternativa explícita: la lista de proxies de confianza.
app.set('trust proxy', ['10.0.0.0/8', '172.16.0.0/12']);

El número debe coincidir exactamente con la cantidad de proxies que hay delante. Si pones 1 y hay dos (CDN + balanceador), obtienes la IP del primer proxy en lugar de la del cliente; si pones 2 y hay uno, obtienes la IP que el cliente quiera. Compruébalo en el entorno real antes de fiarte:

// Endpoint temporal de diagnóstico. Se retira después: expone información de red.
app.get('/diagnostico/ip', (req, res) => {
  res.json({ ip: req.ip, ips: req.ips, xff: req.get('X-Forwarded-For') });
});

Recuerda además que este mismo ajuste afecta a req.protocol y por tanto a las cookies Secure y a las redirecciones HTTPS de 04-02: es una configuración con consecuencias más allá del rate limiting.

  1. Límites por endpoint

Un límite global uniforme es siempre un mal compromiso: demasiado laxo para el login, demasiado estricto para el catálogo. Los límites se ajustan al coste y al riesgo de cada operación.

Endpoint Coste Riesgo Límite Motivo
GET /v1/cafes Bajo (cacheable) Scraping Generoso Es el escaparate
GET /v1/cafes?q=... Alto (búsqueda) Coste Estricto Consulta cara sin caché
POST /v1/sesiones Bajo Fuerza bruta Muy estricto Defensa de cuentas
POST /v1/clientes (registro) Medio (bcrypt, correo) Cuentas basura Estricto bcrypt cuesta CPU a propósito
POST /v1/pedidos Alto (transacción) Coste, stock Moderado Protege la base de datos
POST /v1/pedidos/{id}/pago Alto (tercero) Fraude, coste Estricto Cada intento cuesta dinero
POST /v1/cafes/{id}/imagen Muy alto Almacenamiento Muy estricto Subidas
GET /v1/pedidos Medio Enumeración Moderado Autenticado

El caso del login merece una precisión importante: hay que limitar por dos claves a la vez.

  • Por IP: frena a quien prueba miles de contraseñas contra muchas cuentas desde un sitio.
  • Por correo de destino: frena el credential stuffing distribuido, donde cada intento contra [email protected] viene de una IP distinta (una botnet). El límite por IP no ve nada; el límite por cuenta sí.

Y un matiz que se olvida: solo se cuentan los intentos fallidos. Si cuentas también los correctos, un usuario legítimo que entra y sale varias veces acaba bloqueado. express-rate-limit lo soporta con skipSuccessfulRequests: true.

Cuidado también con el efecto secundario del límite por cuenta: si bloqueas la cuenta durante 15 minutos tras cinco fallos, un atacante puede negar el servicio a un usuario concreto fallando a propósito. Mitigaciones: retraso progresivo en vez de bloqueo duro, ventana corta, y no bloquear si la petición viene de una IP que ya ha entrado con éxito en esa cuenta antes.

  1. Los niveles de Tienda Aroma

Nivel Quién Lectura Escritura Login Cuota diaria
Anónimo Sin token (catálogo público) 60/min por IP 5/15 min por IP y cuenta
Cliente autenticado cliente 300/min 60/min 50.000
Aplicación de terceros CataBox (client_id) 600/min agregado 60/min 100.000
Socio RápidoEnvíos (socio) 1.000/min 300/min 500.000
Panel interno empleado, administrador 2.000/min 600/min Sin cuota
Búsqueda ?q= Cualquiera 20/min

Notas sobre estos números, que importan más que los números mismos:

  • Los valores son un punto de partida. El procedimiento correcto es medir el percentil 99 del uso legítimo real y poner el límite bastante por encima. Un límite que corta a usuarios reales cuesta más que uno demasiado generoso.
  • Empieza en modo observación. express-rate-limit permite contar sin bloquear (skip que solo registra). Dos semanas de datos dicen mucho más que cualquier estimación.
  • El nivel se deriva del token, así que el rate limiting específico por rol debe ir después de autenticar. El límite global anónimo, en cambio, va antes: tiene que proteger incluso de quien manda tokens basura.

  1. La respuesta 429 y sus cabeceras

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 37
Aroma-RateLimit-Limite: 60
Aroma-RateLimit-Restantes: 0
Aroma-RateLimit-Reinicio: 1786000437
Aroma-Traza-Id: trz_9f3a2b7c

{
  "error": {
    "codigo": "limite_peticiones",
    "mensaje": "Has superado el límite de peticiones. Vuelve a intentarlo en 37 segundos.",
    "detalles": []
  }
}
Cabecera Valor Significado
Retry-After 37 Segundos a esperar. La más importante: es estándar y las bibliotecas la leen
Aroma-RateLimit-Limite 60 Peticiones permitidas en la ventana
Aroma-RateLimit-Restantes 0 Cuántas quedan
Aroma-RateLimit-Reinicio 1786000437 Instante Unix en que se restablece

Cuatro decisiones de diseño detrás de esa respuesta:

Retry-After siempre. Sin ella, el cliente educado no sabe cuánto esperar y el maleducado reintenta de inmediato, empeorando el problema. Admite segundos o una fecha HTTP; los segundos son más fáciles y no dependen del reloj del cliente.

Las cabeceras informativas en todas las respuestas, no solo en el 429. Su valor está en que el cliente pueda frenar antes de chocar: si ve Restantes: 3, espacia sus peticiones. Enviarlas solo al fallar desperdicia el mecanismo.

El prefijo Aroma-, coherente con el contrato (nunca X-, como decidimos en 02-05). Existe un borrador de la IETF que estandariza RateLimit-Limit, RateLimit-Remaining y RateLimit-Reset —y una forma combinada RateLimit: limit=60, remaining=0, reset=37—. Cuando se publique como RFC conviene emitir ambas durante un tiempo y documentar la migración; mientras tanto, el prefijo propio evita colisiones con lo que hagan los intermediarios.

El código limite_peticiones del catálogo, con el mismo formato de error que todo lo demás. Un 429 que devuelve texto plano rompe a los clientes que parsean errores.

Y una advertencia de CORS que se paga cara: la SPA no puede leer ninguna de esas cabeceras desde JavaScript salvo que se declaren en Access-Control-Expose-Headers. Es exactamente el tipo de detalle que hace que un mecanismo bien construido no sirva de nada, y lo resolvemos en 04-05.

  1. Implementación: src/middleware/limite-peticiones.js

npm install express-rate-limit
// src/middleware/limite-peticiones.js  (fichero NUEVO)
import rateLimit from 'express-rate-limit';
import { errores } from '../errores/error-api.js';
import { entorno } from '../config/entorno.js';

/**
 * Clave de agrupación: aplicación de terceros > usuario autenticado > IP.
 */
function claveDeLimite(req) {
  if (req.usuario?.clienteOauth) return `oauth:${req.usuario.clienteOauth}`;
  if (req.usuario?.id) return `usr:${req.usuario.id}`;
  return `ip:${req.ip}`;
}

/**
 * Manejador común: fija las cabeceras propias y delega en el middleware de
 * errores de 03-07, para que el cuerpo tenga EXACTAMENTE el formato del catálogo.
 */
function alSuperarse(req, res, next, opciones) {
  const reinicioMs = req.rateLimit.resetTime?.getTime() ?? Date.now() + opciones.windowMs;
  const esperaSegundos = Math.max(1, Math.ceil((reinicioMs - Date.now()) / 1000));

  res.set('Retry-After', String(esperaSegundos));
  res.set('Aroma-RateLimit-Limite', String(req.rateLimit.limit));
  res.set('Aroma-RateLimit-Restantes', '0');
  res.set('Aroma-RateLimit-Reinicio', String(Math.ceil(reinicioMs / 1000)));

  next(
    errores.limitePeticiones(
      'limite_peticiones',
      `Has superado el límite de peticiones. Vuelve a intentarlo en ${esperaSegundos} segundos.`
    )
  );
}

/** Opciones compartidas por todos los limitadores. */
const comunes = {
  standardHeaders: false,   // no emitimos RateLimit-* estándar todavía (ver 04-04 §9)
  legacyHeaders: false,     // NUNCA X-RateLimit-*: el contrato usa el prefijo Aroma-
  keyGenerator: claveDeLimite,
  handler: alSuperarse,
  // En pruebas se desactiva: si no, la suite de 03-08 empieza a fallar sola.
  skip: () => entorno.NODE_ENV === 'pruebas',
};

/** 1. Límite global. Red de seguridad para todo el tráfico. */
export const limiteGlobal = rateLimit({
  ...comunes,
  windowMs: 60_000,
  limit: (req) => {
    if (!req.usuario) return 60;                                   // anónimo
    if (req.usuario.rol === 'empleado' || req.usuario.rol === 'administrador') return 2000;
    if (req.usuario.rol === 'socio') return 1000;
    return 300;                                                    // cliente
  },
});

/** 2. Login: muy estricto y solo sobre los intentos FALLIDOS. */
export const limiteLogin = rateLimit({
  ...comunes,
  windowMs: 15 * 60_000,
  limit: 5,
  skipSuccessfulRequests: true,          // un login correcto no gasta cupo
  keyGenerator: (req) => {
    // Doble clave: IP + cuenta atacada. Frena también el ataque distribuido.
    const correo = (req.body?.email ?? '').toLowerCase().trim();
    return `login:${req.ip}:${correo}`;
  },
});

/** 3. Búsqueda: consulta cara, sin caché. */
export const limiteBusqueda = rateLimit({ ...comunes, windowMs: 60_000, limit: 20 });

/** 4. Escrituras: protegen las transacciones. */
export const limiteEscritura = rateLimit({
  ...comunes,
  windowMs: 60_000,
  limit: (req) => (req.usuario?.rol === 'socio' ? 300 : 60),
});

Y la fábrica de errores que falta en src/errores/error-api.js (el código limite_peticiones ya estaba en el catálogo desde 02-04; solo añadimos su constructor):

// src/errores/error-api.js  (MODIFICADO)
export const errores = {
  // ... noEncontrado, conflicto, noAutenticado, permisoDenegado, datosInvalidos
  limitePeticiones: (codigo, mensaje) => new ErrorApi(429, codigo, mensaje),
  servicioNoDisponible: (codigo, mensaje) => new ErrorApi(503, codigo, mensaje),
};

Dónde se registra en src/app.js

// src/app.js  (extracto tras 04-04)
app.disable('x-powered-by');                 // 1
app.use(asignarTrazaId);                     // 2
app.use(cabecerasSeguridad);                 // 3  helmet (04-02)
// (4) cors → 04-05
// (5) registro → 04-07
app.use(limiteGlobal);                       // 6  ← NUEVO
app.use(express.json({ limit: '100kb', /* ... */ }));   // 7
app.get('/salud', ...);                                 // 8
app.use('/v1', rutasV1);                                // 9
app.use(manejadorNoEncontrado);                         // 10
app.use(manejadorErrores);                              // 11

Por qué en la posición 6, y no antes ni después:

  • Después de helmet y CORS, para que el 429 lleve las cabeceras de seguridad y, sobre todo, las de CORS: si no, la SPA recibe un error de red opaco en lugar de un 429 legible.
  • Antes del parser de JSON. Si el limitador fuera después, tu servidor estaría parseando y validando 100 kB de JSON de peticiones que va a rechazar igualmente. Rechazar barato es media defensa de disponibilidad.
  • Antes de las rutas, obviamente, porque protege a todas.
  • Después del registro (posición 5, 04-07), para que los 429 queden registrados: si no, el ataque es invisible en los logs justo cuando más falta hace verlo.

Hay una excepción deliberada a "antes del parser": limiteLogin necesita req.body.email para su clave, así que se registra dentro de la ruta, después del parser:

// src/rutas/sesiones.js  (MODIFICADO)
router.post(
  '/',
  limiteLogin,                          // ← ANTES de autenticar: no hay usuario todavía
  validar(esquemaLogin, 'body'),
  asincrono(controladores.sesiones.crear)
);

// src/rutas/cafes.js  (MODIFICADO)
router.get(
  '/',
  autenticarOpcional,
  (req, res, next) => (req.query.q ? limiteBusqueda(req, res, next) : next()),
  validar(esquemaListarCafes, 'query'),
  asincrono(controladores.cafes.listar)
);

// src/rutas/pedidos.js  (MODIFICADO)
router.post(
  '/',
  autenticar,
  exigirRol('cliente', 'empleado', 'administrador'),
  limiteEscritura,                      // ← DESPUÉS de autenticar: la clave es el usuario
  exigirClaveIdempotencia,
  validar(esquemaCrearPedido, 'body'),
  asincrono(controladores.pedidos.crear)
);

La regla general que resume el orden: el límite anónimo va antes de autenticar; el límite por usuario va después.

  1. Almacén en memoria frente a Redis

El almacén por defecto de express-rate-limit es un Map en el proceso. Con una sola instancia funciona. Con tres, ocurre esto:

graph TD
  C[Cliente: 60 peticiones/min] --> B[Balanceador]
  B -->|20 peticiones| A1[Instancia 1: cuenta 20 de 60 - permite]
  B -->|20 peticiones| A2[Instancia 2: cuenta 20 de 60 - permite]
  B -->|20 peticiones| A3[Instancia 3: cuenta 20 de 60 - permite]
  A1 --> R[Limite real aplicado: 180/min con un limite de 60]
  A2 --> R
  A3 --> R

El límite efectivo se multiplica por el número de instancias, y además es errático: depende de cómo reparta el balanceador. Peor aún, un despliegue reinicia los procesos y borra todos los contadores, así que un atacante solo tiene que esperar a tu siguiente despliegue.

Memoria Redis
Instancias 1 N
Precisión con N instancias Límite × N Exacta
Superviviente a reinicios No
Latencia añadida 0 ~1 ms en la misma red
Punto único de fallo No Sí: hay que decidir qué pasa si Redis cae
Complejidad Nula Un servicio más que operar
npm install rate-limit-redis ioredis
// src/config/redis.js  (fichero NUEVO)
import Redis from 'ioredis';
import { entorno } from './entorno.js';

export const redis = new Redis(entorno.REDIS_URL, {
  maxRetriesPerRequest: 2,
  enableOfflineQueue: false,   // si Redis está caído, falla rápido en vez de encolar
});

redis.on('error', (e) => {
  // No se lanza: la API debe seguir sirviendo aunque Redis esté caído.
  console.error('Redis no disponible:', e.message);
});
// src/middleware/limite-peticiones.js  (MODIFICADO)
import RedisStore from 'rate-limit-redis';
import { redis } from '../config/redis.js';

const almacen = entorno.REDIS_URL
  ? new RedisStore({
      sendCommand: (...args) => redis.call(...args),
      prefix: 'aroma:rl:',      // prefijo para no colisionar con la caché de 04-06
    })
  : undefined;                  // sin REDIS_URL, almacén en memoria (desarrollo)

const comunes = {
  // ... resto igual
  store: almacen,
};

Qué hacer si Redis cae es una decisión de diseño explícita, no un detalle:

  • Fail-open (dejar pasar): la API sigue funcionando sin límites. Prioriza disponibilidad; es la opción por defecto de la mayoría y la que elige Tienda Aroma para el límite general.
  • Fail-closed (rechazar): más seguro pero convierte una caída de Redis en una caída total.

Un compromiso razonable: fail-open en el límite general, fail-closed en el login, donde el riesgo de fuerza bruta pesa más que la disponibilidad. Y en ambos casos, una alerta: quedarte sin rate limiting y no enterarte es peor que cualquiera de las dos opciones.

  1. Otras defensas de disponibilidad

El rate limiting no llega a todo. El cuadro completo:

Defensa Qué evita Estado en el proyecto
Límite de cuerpo (limit: '100kb') POST de 500 MB Puesto en 03-02
Timeouts de servidor Conexiones abiertas eternas (Slowloris) Abajo
Timeouts de salida Que un tercero lento bloquee tus procesos En cada fetch
Límite de expandir Consultas exponenciales Profundidad 2 + lista blanca (04-01)
limite máximo 100 Páginas gigantes 03-03
desplazamiento máximo OFFSET que barre la tabla 03-03
Coste por consulta Que todas las peticiones cuenten igual Abajo
Circuit breaker Machacar un servicio caído Abajo
Backpressure Aceptar más carga de la que puedes procesar Cola acotada

Timeouts del servidor. Node acepta conexiones que no envían nada, y un ataque Slowloris las usa para agotar el pool:

// src/servidor.js  (MODIFICADO)
const servidor = app.listen(entorno.PUERTO);

servidor.headersTimeout = 10_000;      // 10 s para enviar las cabeceras completas
servidor.requestTimeout = 30_000;      // 30 s para toda la petición
servidor.keepAliveTimeout = 65_000;    // mayor que el del balanceador (típico 60 s)

El keepAliveTimeout es una fuente clásica de 502 aleatorios: si tu servidor cierra la conexión reutilizada antes que el balanceador, el balanceador envía una petición por una conexión que se está cerrando. La regla es que el de Node sea mayor que el del proxy.

Coste por consulta. No todas las peticiones cuestan lo mismo, y el cubo de fichas del apartado 4 ya admite un coste:

Operación Coste en fichas
GET /v1/cafes/{id} 1
GET /v1/cafes?limite=100 3
GET /v1/cafes?q=... 5
GET /v1/pedidos?expandir=lineas.cafe 5
POST /v1/pedidos 10

Es más justo que contar peticiones y es lo que hacen las APIs maduras (GitHub lo llama «puntos»). Requiere documentarlo bien, porque un límite en unidades abstractas es más difícil de entender.

Circuit breaker. Si la pasarela de pago está caída, seguir llamándola con timeout de 30 segundos consume tus procesos y retrasa la recuperación del otro. El patrón tiene tres estados: cerrado (todo pasa), abierto (tras N fallos, se rechaza de inmediato sin llamar) y semiabierto (pasado un tiempo se deja pasar una petición de prueba). Es lo que convierte «el pago está caído» en un 503 rápido en lugar de en una caída general.

Backpressure. Cuando el trabajo entrante supera al que puedes procesar, la respuesta correcta es rechazar (503), no encolar indefinidamente. Una cola sin tope solo cambia una caída rápida por una lenta y con toda la memoria consumida.

  1. 503, Retry-After y servicio_no_disponible

429 y 503 se confunden y no son lo mismo:

429 503
Significa has pedido demasiado Nosotros no podemos ahora
Culpa Del cliente Del servidor
Otros clientes Están bien También afectados
Código limite_peticiones servicio_no_disponible
Retry-After Sí, calculado Sí, estimado
HTTP/1.1 503 Service Unavailable
Retry-After: 120
Content-Type: application/json

{
  "error": {
    "codigo": "servicio_no_disponible",
    "mensaje": "El servicio no está disponible temporalmente. Inténtalo de nuevo en 2 minutos.",
    "detalles": [],
    "trazaId": "trz_9f3a2b7c"
  }
}

Nota el trazaId: es un 5xx, así que el contrato de 03-07 lo exige. En el 429, que es 4xx, no aparece.

Cuándo se usa 503 en Tienda Aroma: mantenimiento programado, base de datos no disponible, circuit breaker abierto hacia la pasarela de pago, o sobrecarga detectada por backpressure. Y siempre con Retry-After, aunque sea una estimación: sin ella, todos los clientes reintentan a la vez y el efecto es el rebaño atronador que impide que el servicio se recupere.

  1. Qué hace un cliente bien educado

Del otro lado del contrato, esto es lo que debe hacer quien consume:

/**
 * Cliente HTTP con reintentos correctos para la API de Tienda Aroma.
 *
 * Reglas:
 *  - Solo reintenta lo que es seguro reintentar.
 *  - Respeta Retry-After cuando el servidor lo indica.
 *  - Backoff exponencial con jitter cuando no lo indica.
 *  - Tope de intentos: reintentar para siempre es un ataque.
 */
const REINTENTABLES = new Set([429, 502, 503, 504]);

function esperar(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

export async function peticionConReintentos(url, opciones = {}, maxIntentos = 4) {
  const metodo = (opciones.method ?? 'GET').toUpperCase();
  // Solo se reintentan métodos idempotentes, o POST con clave de idempotencia.
  const seguroReintentar =
    ['GET', 'HEAD', 'PUT', 'DELETE'].includes(metodo) ||
    Boolean(opciones.headers?.['Idempotency-Key']);

  for (let intento = 1; intento <= maxIntentos; intento++) {
    const respuesta = await fetch(url, opciones);

    if (!REINTENTABLES.has(respuesta.status) || !seguroReintentar) return respuesta;
    if (intento === maxIntentos) return respuesta;   // se devuelve el fallo, no se oculta

    // 1. Si el servidor dice cuánto esperar, se le hace caso. Punto.
    const retryAfter = Number(respuesta.headers.get('Retry-After'));
    let esperaMs;
    if (Number.isFinite(retryAfter) && retryAfter > 0) {
      esperaMs = retryAfter * 1000;
    } else {
      // 2. Si no, backoff exponencial: 1 s, 2 s, 4 s, 8 s...
      const base = 1000 * 2 ** (intento - 1);
      // 3. Jitter: hasta ±50 % aleatorio. IMPRESCINDIBLE.
      esperaMs = base * (0.5 + Math.random());
    }

    // 4. Tope absoluto: nunca esperar más de 60 s.
    await esperar(Math.min(esperaMs, 60_000));
  }
}

Por qué el jitter no es opcional. Si mil clientes reciben un 503 en el mismo segundo y todos esperan exactamente 1, 2, 4 y 8 segundos, los mil vuelven a la vez cuatro veces seguidas. El servicio, que estaba recuperándose, se cae de nuevo con cada oleada. La aleatoriedad reparte la vuelta en el tiempo y es la diferencia entre recuperarse en un minuto o no recuperarse. Es un fallo de sistemas distribuidos tan común que tiene nombre propio: rebaño atronador.

Y las tres reglas complementarias:

  • Frenar antes de chocar: si Aroma-RateLimit-Restantes baja de un umbral, espaciar las peticiones en lugar de esperar al 429.
  • No reintentar los 4xx de cliente. Un 400 o un 422 van a fallar igual la segunda vez. Solo 429 y 5xx.
  • Cachear. El mejor reintento es la petición que no se hace: si el catálogo no ha cambiado, no vuelvas a pedirlo. Ese es exactamente el tema de 04-06.

  1. Cómo se comunican los límites en la documentación

Un límite no documentado es un fallo intermitente desde el punto de vista del consumidor. La documentación debe responder a cinco preguntas, y conviene que estén en una sola página:

  1. Cuáles son los límites, por nivel y por endpoint, en una tabla como la del apartado 8.
  2. Cómo saber cuánto queda: las cabeceras Aroma-RateLimit-*, con un ejemplo real.
  3. Qué pasa al superarlos: el 429 completo, con su cuerpo y su Retry-After.
  4. Qué debe hacer el cliente: el patrón de backoff, con código copiable como el del apartado 14.
  5. Cómo pedir más: a quién escribir, con qué justificación y en qué plazo.

En openapi.yaml se declara la respuesta 429 en las operaciones afectadas, con sus cabeceras:

components:
  responses:
    LimitePeticiones:
      description: Se ha superado el límite de peticiones.
      headers:
        Retry-After:
          description: Segundos que hay que esperar antes de reintentar.
          schema: { type: integer, example: 37 }
        Aroma-RateLimit-Limite:
          description: Peticiones permitidas en la ventana actual.
          schema: { type: integer, example: 60 }
        Aroma-RateLimit-Restantes:
          description: Peticiones que quedan en la ventana actual.
          schema: { type: integer, example: 0 }
        Aroma-RateLimit-Reinicio:
          description: Instante Unix (segundos) en que se restablece la ventana.
          schema: { type: integer, example: 1786000437 }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              codigo: limite_peticiones
              mensaje: Has superado el límite de peticiones. Vuelve a intentarlo en 37 segundos.
              detalles: []

  1. Dónde vive el rate limiting en producción

El middleware de Express funciona, pero tiene una limitación estructural: para rechazar la petición, ya la has recibido. Tu servidor ha aceptado la conexión TCP, ha negociado TLS y ha ejecutado middleware. Bajo un ataque de verdad, eso ya es demasiado trabajo.

Por eso, en producción, el rate limiting suele vivir en capas:

Capa Qué frena Ventaja Limitación
CDN / WAF (Cloudflare, CloudFront) Volumen bruto, DDoS, bots El tráfico ni llega a tu red No conoce tu lógica de negocio
API gateway (Kong, Apigee, AWS API Gateway) Límites por consumidor y plan Centralizado, sin tocar código Un componente más que operar
Balanceador / nginx Conexiones y tasa por IP Barato y muy rápido Solo conoce IPs
Aplicación (lo que hemos hecho) Reglas de negocio: por rol, por endpoint, coste Conoce el contexto: quién es y qué pide Consume recursos del proceso

Las capas se complementan, no se sustituyen. La CDN frena el DDoS que tu proceso nunca aguantaría; solo la aplicación sabe que un socio puede hacer 1.000 por minuto y un cliente 300. Mantener el límite en la aplicación tiene además una ventaja práctica: es la defensa que sigue existiendo si alguien despliega la API sin la CDN delante, o si un atacante encuentra la IP de origen y la llama directamente. Los gateways y sus portales los veremos en 05-06.

  1. Pruebas del límite con node:test

// pruebas/integracion/limite-peticiones.prueba.js
import { describe, it, before } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import express from 'express';
import rateLimit from 'express-rate-limit';
import { manejadorErrores } from '../../src/middleware/errores.js';
import { errores } from '../../src/errores/error-api.js';

/**
 * Ojo: el `skip` de entorno 'pruebas' desactiva los limitadores reales para que
 * la suite de 03-08 no se rompa. Por eso aquí montamos una app mínima con un
 * limitador propio: probamos el COMPORTAMIENTO, no la configuración global.
 */
function crearAppConLimite(limite = 3) {
  const app = express();
  app.use(
    rateLimit({
      windowMs: 60_000,
      limit: limite,
      standardHeaders: false,
      legacyHeaders: false,
      keyGenerator: (req) => req.ip,
      handler: (req, res, next) => {
        const reinicio = req.rateLimit.resetTime.getTime();
        const espera = Math.max(1, Math.ceil((reinicio - Date.now()) / 1000));
        res.set('Retry-After', String(espera));
        res.set('Aroma-RateLimit-Limite', String(req.rateLimit.limit));
        res.set('Aroma-RateLimit-Restantes', '0');
        next(errores.limitePeticiones('limite_peticiones', `Espera ${espera} segundos.`));
      },
    })
  );
  app.get('/v1/cafes', (req, res) => res.json({ datos: [], total: 0 }));
  app.use(manejadorErrores);
  return app;
}

describe('rate limiting', () => {
  it('permite hasta el límite y rechaza la siguiente con 429', async () => {
    const app = crearAppConLimite(3);

    for (let i = 1; i <= 3; i++) {
      const r = await request(app).get('/v1/cafes');
      assert.equal(r.status, 200, `la petición ${i} debía pasar`);
    }

    const r = await request(app).get('/v1/cafes');
    assert.equal(r.status, 429);
    assert.equal(r.body.error.codigo, 'limite_peticiones');
    assert.deepEqual(r.body.error.detalles, []);       // el contrato exige detalles siempre
    assert.ok(!('trazaId' in r.body.error), 'trazaId solo en 5xx');
  });

  it('el 429 incluye Retry-After y las cabeceras Aroma-RateLimit', async () => {
    const app = crearAppConLimite(1);
    await request(app).get('/v1/cafes');
    const r = await request(app).get('/v1/cafes');

    assert.equal(r.status, 429);
    const espera = Number(r.headers['retry-after']);
    assert.ok(Number.isInteger(espera) && espera > 0, 'Retry-After debe ser entero positivo');
    assert.equal(r.headers['aroma-ratelimit-limite'], '1');
    assert.equal(r.headers['aroma-ratelimit-restantes'], '0');
    assert.ok(!r.headers['x-ratelimit-limit'], 'no deben emitirse cabeceras X-');
  });

  it('claves distintas no comparten cupo', async () => {
    const app = crearAppConLimite(1);
    await request(app).get('/v1/cafes').set('X-Forwarded-For', '203.0.113.1');
    // Sin trust proxy, Supertest usa siempre 127.0.0.1: esta prueba verifica
    // que el cupo es por clave, así que el limitador debe distinguirlas.
    const r = await request(app).get('/v1/cafes').set('X-Forwarded-For', '203.0.113.2');
    assert.ok([200, 429].includes(r.status));   // depende de trust proxy: ver comentario
  });
});

Tres cosas que enseña esta prueba:

  • El skip en entorno de pruebas es necesario pero peligroso. Necesario porque, sin él, la suite de 03-08 empezaría a fallar de forma aleatoria en cuanto hiciera más de 60 peticiones. Peligroso porque significa que los limitadores reales no se prueban: por eso montamos una app mínima con el mismo handler.
  • Se prueba el contrato, no la implementación: código, formato del cuerpo, cabeceras presentes y cabeceras ausentes (X-RateLimit-* no debe aparecer).
  • La tercera prueba está deliberadamente floja y su comentario lo explica: sin trust proxy configurado, Supertest siempre parece venir de 127.0.0.1. Probar la separación por IP requiere configurar trust proxy en la app de prueba; es un recordatorio de que probar el rate limiting por IP obliga a replicar la topología de red.

Para probar el límite basado en tiempo sin esperar de verdad, usa el reloj falso de node:test:

import { mock } from 'node:test';
mock.timers.enable({ apis: ['Date', 'setTimeout'] });
mock.timers.tick(61_000);     // avanza un minuto: la ventana se ha renovado

Errores Comunes y Consejos

Poner el limitador después del parser de JSON. Parseas 100 kB de peticiones que vas a rechazar. Rechaza lo antes posible.

Poner el limitador antes de CORS. El navegador recibe un error de red opaco en lugar de un 429, y el desarrollador de la SPA pierde una tarde.

Usar el almacén en memoria con varias instancias. El límite se multiplica por el número de procesos y se borra en cada despliegue.

Confiar en X-Forwarded-For sin configurar trust proxy correctamente. O limitas a todo el mundo junto, o el atacante cambia de identidad a voluntad.

Contar los logins correctos. Un usuario legítimo que entra y sale acaba bloqueado.

Bloquear cuentas por intentos fallidos sin más. Se convierte en una forma de negar el servicio a un usuario concreto. Retraso progresivo mejor que bloqueo duro.

No enviar Retry-After. El cliente educado no sabe cuánto esperar y el maleducado no espera nada.

Enviar las cabeceras de cupo solo en el 429. Su valor está en que el cliente frene antes de chocar.

Olvidar Access-Control-Expose-Headers. La SPA no puede leer ninguna cabecera propia y todo el mecanismo es invisible para ella (04-05).

Consejo: despliega en modo observación primero. Cuenta sin bloquear durante dos semanas, mira el percentil 99 real y fija el límite bastante por encima.

Consejo: registra los 429 con su clave. Saber que vienen todos de oauth:catabox cambia el diagnóstico por completo (04-07).

Consejo: exime a tu propia monitorización. Nada peor que tu comprobación de salud gastando cupo y disparando alertas falsas.

Ejercicios

Ejercicio 1: elegir algoritmo y clave

Para cada situación, elige el algoritmo (ventana fija, deslizante, cubo de fichas) y la clave, y justifícalo:

  1. Proteger POST /v1/sesiones de la fuerza bruta.
  2. Permitir que la SPA cargue una pantalla que hace 8 peticiones seguidas, sin castigarla.
  3. Limitar a CataBox globalmente, aunque actúe en nombre de 500 usuarios distintos.
  4. Limitar GET /v1/cafes?q= porque cada búsqueda cuesta 200 ms de CPU.

Ejercicio 2: cliente con backoff

Escribe una función descargarCatalogo(paginas) que recorra GET /v1/cafes?limite=100&desplazamiento=N durante paginas páginas, respetando el rate limiting: debe frenar preventivamente cuando Aroma-RateLimit-Restantes sea bajo, respetar Retry-After en un 429 y no reintentar más de tres veces por página.

Ejercicio 3: diagnosticar un incidente

Tras desplegar el rate limiting, soporte recibe estas quejas en el mismo día. Diagnostica cada una y propón la corrección:

  • (a) «Desde la oficina de mi empresa, la web deja de funcionar por las mañanas. Desde casa va bien.»
  • (b) «Mi app móvil recibe 429 al abrir la pantalla de inicio, pero solo la primera vez tras estar un rato cerrada.» (nota: la app hace 8 peticiones al arrancar)
  • (c) «Nuestro script de integración recibe 429 aleatoriamente aunque hacemos 50 peticiones por minuto y el límite es 300.»
  • (d) «La SPA muestra "error de red" en vez del mensaje de límite.»

Soluciones

Solución 1

Caso Algoritmo Clave Justificación
1. Login Ventana deslizante estricta ip + email (doble) No debe permitirse ninguna ráfaga: 5 intentos son 5, no 10 en el borde. La doble clave frena tanto al atacante concentrado (IP) como al distribuido por botnet (cuenta)
2. Pantalla de la SPA Cubo de fichas Usuario autenticado Es exactamente el caso para el que sirve: capacidad 20, tasa 5/s permite la ráfaga de 8 y mantiene acotada la tasa sostenida
3. CataBox Ventana deslizante con contador client_id agregado Limitar por usuario no serviría: son 500 claves distintas. La clave debe ser solo oauth:catabox, ignorando el sub. Idealmente, los dos límites a la vez: por usuario y agregado por cliente
4. Búsqueda Cubo de fichas con coste Usuario o IP Coste 5 fichas por búsqueda frente a 1 por lectura normal: refleja el coste real y no obliga a un contador aparte

Solución 2

const UMBRAL_FRENADO = 10;   // por debajo de esto, espaciamos

function esperar(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

export async function descargarCatalogo(paginas, token) {
  const cafes = [];

  for (let pagina = 0; pagina < paginas; pagina++) {
    const url = `https://api.tiendaaroma.example/v1/cafes?limite=100&desplazamiento=${pagina * 100}`;
    let respuesta;

    for (let intento = 1; intento <= 3; intento++) {
      respuesta = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });

      if (respuesta.status !== 429) break;
      if (intento === 3) throw new Error(`429 persistente en la página ${pagina}`);

      // El servidor dice cuánto esperar: se le hace caso, con jitter para no
      // sincronizarnos con otros clientes que hayan recibido el mismo 429.
      const retryAfter = Number(respuesta.headers.get('Retry-After')) || 2 ** intento;
      await esperar(retryAfter * 1000 * (1 + Math.random() * 0.2));
    }

    if (!respuesta.ok) throw new Error(`Error ${respuesta.status} en la página ${pagina}`);

    const cuerpo = await respuesta.json();
    cafes.push(...cuerpo.datos);
    if (cafes.length >= cuerpo.total) break;      // no pedir páginas vacías

    // Frenado PREVENTIVO: mejor ir despacio que chocar contra el 429.
    const restantes = Number(respuesta.headers.get('Aroma-RateLimit-Restantes'));
    const reinicio = Number(respuesta.headers.get('Aroma-RateLimit-Reinicio'));
    if (Number.isFinite(restantes) && restantes < UMBRAL_FRENADO) {
      const segundosHastaReinicio = Math.max(1, reinicio - Math.floor(Date.now() / 1000));
      // Se reparte lo que queda de ventana entre las peticiones que aún podemos hacer.
      await esperar((segundosHastaReinicio / Math.max(1, restantes)) * 1000);
    }
  }

  return cafes;
}

Lo esencial: el frenado preventivo hace que el 429 casi nunca ocurra, el Retry-After se respeta cuando ocurre, el jitter evita sincronizarse con otros clientes, hay tope de intentos y se sale del bucle cuando ya se tienen todos los elementos según total.

Solución 3

(a) NAT. Toda la oficina sale por una IP pública. Con 60/min anónimos, veinte empleados navegando la agotan. Correcciones: subir el límite anónimo; autenticar cuanto antes para pasar al límite por usuario (300/min); y, si la SPA carga el catálogo sin sesión, aprovechar la caché HTTP (04-06) para que la mayoría de esas peticiones ni lleguen.

(b) Efecto de borde con ventana fija. La app hace 8 peticiones de golpe. Si el límite se implementó con ventana fija y estricta, una ráfaga tras un periodo de inactividad puede caer justo en el borde. Corrección: cubo de fichas con capacidad ≥ 20 y tasa 5/s, que es justo el caso de uso para el que sirve. Si el problema fuera de escala y no de ráfaga, la solución alternativa es reducir las 8 llamadas a 1 con expandir, como vimos en 04-01.

(c) Almacén en memoria con varias instancias... al revés. Con memoria y N instancias el límite se multiplica, así que 50/min nunca daría 429. Que sí lo dé apunta a otra causa: la clave está mal elegida y agrupa a varios consumidores. Muy probablemente el script no manda Authorization, cae en la rama ip: y comparte IP con otros procesos del mismo cliente. Corrección: autenticar el script (Client Credentials, 04-03) para que su clave sea su client_id. Otra posibilidad: trust proxy mal configurado hace que todos los clientes compartan la IP del balanceador.

(d) Falta Access-Control-Expose-Headers, o el limitador está antes que CORS. Si el 429 se emite sin las cabeceras Access-Control-Allow-Origin, el navegador bloquea la respuesta y JavaScript solo ve un fallo genérico de red. Correcciones: registrar cors antes que limiteGlobal en src/app.js (es la posición 4 frente a la 6) y exponer Retry-After y Aroma-RateLimit-* en Access-Control-Expose-Headers (04-05).

Conclusión

La disponibilidad es la única propiedad de una API que puede destruir cualquiera sin explotar ninguna vulnerabilidad: basta con llamarla mucho. Has visto por qué toda API pública necesita límites —abuso, scraping, clientes con bucles, coste, equidad y protección de la base de datos—, la diferencia entre rate limiting, throttling y cuotas, y los cuatro algoritmos con su compromiso real entre precisión, memoria y tolerancia a ráfagas, con un cubo de fichas implementado y comentado que incluye la recarga perezosa y la limpieza que casi nadie escribe. Sabes contra qué se cuenta y por qué la IP es una clave problemática entre NAT, IPv6 y proxies, con la configuración exacta de trust proxy; tienes los límites por endpoint y por nivel de Tienda Aroma, y la respuesta 429 completa con Retry-After y las cabeceras Aroma-RateLimit-*. En el proyecto está src/middleware/limite-peticiones.js con express-rate-limit, registrado en la posición 6 de src/app.js —después de helmet y CORS, antes del parser de JSON—, con almacén en Redis para varias instancias y una decisión explícita de qué hacer si Redis cae. Y has añadido timeouts, coste por consulta, circuit breaker y el 503 con servicio_no_disponible, además del cliente con backoff exponencial y jitter que evita el rebaño atronador.

Hay un detalle que ha aparecido tres veces y que ya no se puede posponer: la SPA no puede leer ninguna de las cabeceras que acabamos de diseñar. En 04-05, CORS y políticas de seguridad, empezaremos por el porqué: la política del mismo origen del navegador, qué es exactamente un origen y por qué curl y Aroma Móvil no se ven afectados. Veremos peticiones simples frente a preflight con el intercambio OPTIONS completo en crudo, todas las cabeceras del protocolo —incluida Access-Control-Expose-Headers, que es la que resuelve este problema—, la configuración real de Tienda Aroma con lista blanca por entorno, por qué * y Allow-Credentials son incompatibles, la tabla de errores de consola con su causa y su solución, el clásico preflight que devuelve 401 porque la autenticación se ejecutó antes que CORS, y por qué usamos Authorization: Bearer en vez de cookies —lo que nos hace inmunes a CSRF—.

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