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
- Por qué toda API pública necesita límites
- Rate limiting, throttling y cuotas
- Los cuatro algoritmos
- Implementación comentada del cubo de fichas
- La clave: contra qué se cuenta
- El problema de la IP: NAT, proxies y
trust proxy - Límites por endpoint
- Los niveles de Tienda Aroma
- La respuesta 429 y sus cabeceras
- Implementación:
src/middleware/limite-peticiones.js - Almacén en memoria frente a Redis
- Otras defensas de disponibilidad
- 503,
Retry-Afteryservicio_no_disponible - Qué hace un cliente bien educado
- Cómo se comunican los límites en la documentación
- Dónde vive el rate limiting en producción
- Pruebas del límite con
node:test
- 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.
- 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.
- Los cuatro algoritmos
Ventana fija
Se cuenta cuántas peticiones hay en el minuto actual; al cambiar de minuto, el contador vuelve a cero.
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.
- 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.
- 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.
- El problema de la IP: NAT, proxies y
trust proxy
trust proxyLimitar 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:
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.
- 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.
- 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-limitpermite contar sin bloquear (skipque 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.
- 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.
- Implementación:
src/middleware/limite-peticiones.js
src/middleware/limite-peticiones.js// 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); // 11Por qué en la posición 6, y no antes ni después:
- Después de helmet y CORS, para que el
429lleve las cabeceras de seguridad y, sobre todo, las de CORS: si no, la SPA recibe un error de red opaco en lugar de un429legible. - 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
429queden 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.
- 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 | Sí |
| 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 |
// 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.
- 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.
- 503,
Retry-After y servicio_no_disponible
Retry-After y servicio_no_disponible429 y 503 se confunden y no son lo mismo:
| 429 | 503 | |
|---|---|---|
| Significa | Tú 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.
- 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-Restantesbaja de un umbral, espaciar las peticiones en lugar de esperar al429. - No reintentar los
4xxde cliente. Un400o un422van a fallar igual la segunda vez. Solo429y5xx. - 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.
- 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:
- Cuáles son los límites, por nivel y por endpoint, en una tabla como la del apartado 8.
- Cómo saber cuánto queda: las cabeceras
Aroma-RateLimit-*, con un ejemplo real. - Qué pasa al superarlos: el
429completo, con su cuerpo y suRetry-After. - Qué debe hacer el cliente: el patrón de backoff, con código copiable como el del apartado 14.
- 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: []
- 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.
- Pruebas del límite con
node:test
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
skipen 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 mismohandler. - 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 proxyconfigurado, Supertest siempre parece venir de127.0.0.1. Probar la separación por IP requiere configurartrust proxyen 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 renovadoErrores 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:
- Proteger
POST /v1/sesionesde la fuerza bruta. - Permitir que la SPA cargue una pantalla que hace 8 peticiones seguidas, sin castigarla.
- Limitar a CataBox globalmente, aunque actúe en nombre de 500 usuarios distintos.
- 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
- ¿Qué es una API?
- Historia y evolución de las APIs
- Fundamentos de HTTP para APIs
- Principios básicos de REST
- Modelo de madurez de Richardson y HATEOAS
- REST vs. SOAP
- REST frente a GraphQL, gRPC y webhooks
Módulo 2: Diseño de APIs RESTful
- Principios de diseño de APIs RESTful
- Recursos y URIs
- Métodos HTTP
- Códigos de estado HTTP
- Representaciones, cabeceras y negociación de contenido
- Filtrado, ordenación, paginación y búsqueda
- Versionado de APIs
- Documentación de APIs
Módulo 3: Desarrollo de APIs RESTful
- Configuración del entorno de desarrollo
- Creación de un servidor básico
- Manejo de peticiones y respuestas
- Validación de datos de entrada
- Persistencia y capa de acceso a datos
- Autenticación y autorización
- Manejo de errores
- Pruebas y validación
Módulo 4: Buenas Prácticas y Seguridad
- Buenas prácticas en el diseño de APIs
- Seguridad en APIs RESTful
- OAuth 2.0 y OpenID Connect en la práctica
- Rate limiting y throttling
- CORS y políticas de seguridad
- Caché HTTP y rendimiento
- Observabilidad: logs, métricas y trazas
Módulo 5: Herramientas y Frameworks
- Postman para pruebas de APIs
- Swagger y OpenAPI para documentación
- Frameworks populares para APIs RESTful
- Contratos, mocks y pruebas automatizadas de API
- Integración continua y despliegue
- API gateways y portales de desarrollador
