wandres.dev
EMAIL, TURNSTILE, RATE LIMITING · utilidades de borde

Rate limiting nativo: el contador por clave que no escribes

Limitar la tasa parece un ejercicio de una tarde hasta que intentas hacerlo en el edge, donde no hay un servidor con un diccionario en memoria sino cientos de ubicaciones sin estado compartido. Las salidas caseras son malas de formas distintas: KV es eventual y llega tarde, y un Durable Object es exacto pero convierte tu limitador en un cuello de botella con domicilio. El binding nativo ofrece un tercer camino, un contador local por clave con coste próximo a cero y garantías deliberadamente débiles. Vemos su configuración, la elección de la clave entre IP, usuario y endpoint, la respuesta 429 bien hecha, y por qué ese límite es aproximado y cuándo eso es exactamente lo que quieres.

⏱ 16 min

Limitar la tasa parece un ejercicio trivial: un contador, una ventana, un rechazo. Lo es en un servidor único con un diccionario en memoria. En el edge, donde tu código corre a la vez en cientos de ubicaciones sin memoria compartida, ese contador se convierte en un problema de consenso distribuido, y cualquier solución honesta te obliga a elegir qué sacrificar. El binding nativo de límite de tasa hace esa elección por ti y la hace de forma explícita: renuncia a la exactitud global para darte una comprobación local con coste próximo a cero. Entender por qué esa renuncia es sensata, y dónde deja de serlo, es toda la lección.

🎯 Al terminar esta lección sabrás
  • Ver por qué un limitador propio sobre KV o Durable Objects sale caro o llega tarde.
  • Declarar el binding de límite de tasa y comprobarlo con limit.
  • Diseñar la clave: por IP, por usuario, por endpoint y por composición de ambos.
  • Aceptar la naturaleza aproximada del contador y saber cuándo no basta.

Las tres salidas y sus facturas

Antes de que existiera el binding, quien necesitaba limitar en el edge tenía tres caminos y ninguno gratis. El primero era KV: leer un contador, incrementarlo, escribirlo. Barato de leer, pero su consistencia es eventual, de modo que en el instante exacto de una ráfaga —justo cuando lo necesitas— el valor que lees está desactualizado y el atacante pasa mientras la propagación viaja.

El segundo camino era un Durable Object, que sí da un contador exacto porque toda la cuenta ocurre en un único hilo. El precio es de manual: ese objeto vive en un solo lugar del planeta, así que cada petición del mundo entero paga un salto de red hasta él antes de ser atendida, y has convertido tu defensa en el punto más lento y más frágil de tu sistema.

El tercer camino era no limitar en el Worker sino en la WAF, con reglas de tasa a nivel de zona. Sigue siendo una capa excelente y conviene tenerla, porque frena en la frontera sin gastar ni un milisegundo de tu CPU. Lo que no puede hacer es mirar dentro de tu lógica: no sabe qué usuario ha iniciado sesión, ni qué plan tiene contratado, ni si esta petición concreta es la quinta de un flujo caro.

ℹ️
La cuarta salida es aceptar un contador aproximado

El binding nativo no coordina nada entre ubicaciones. Cuenta donde está corriendo tu Worker, y por eso responde en microsegundos sin ninguna subpetición. La consecuencia es directa y hay que decirla sin rodeos: el número que configuras se aplica por ubicación, no al planeta entero, así que un cliente distribuido puede superar el total configurado. Para contener abusos y bucles, eso sobra. Para facturar una cuota, no sirve.

El binding y su forma

Se declara en el manifiesto con un identificador de espacio, un límite y un periodo. El periodo admite dos valores, diez o sesenta segundos, y esa restricción no es un capricho: es lo que permite que el contador sea barato.

{
  "ratelimits": [
    {
      "name": "LIMITE_LOGIN",
      "namespace_id": "1001",
      "simple": { "limit": 5, "period": 60 }
    },
    {
      "name": "LIMITE_API",
      "namespace_id": "1002",
      "simple": { "limit": 100, "period": 60 }
    }
  ]
}

Declara un espacio por operación en lugar de uno solo compartido. El identificador de espacio separa los contadores, de modo que agotar el cupo del login no consume el de la API, y te permite afinar cada número por separado sin tocar los demás. Cambiar el límite o el periodo de un espacio en caliente es una decisión de despliegue, no de código: conviene tenerlo presente porque el ajuste fino de estos números ocurre siempre después de ver tráfico real, nunca antes.

El uso es una sola llamada que devuelve un objeto con success. No hay que incrementar nada a mano ni liberar nada después: la propia llamada cuenta.

export default {
  async fetch(request, env): Promise<Response> {
    const ip = request.headers.get("CF-Connecting-IP") ?? "desconocida";
    const { success } = await env.LIMITE_API.limit({ key: ip });

    if (!success) {
      return new Response("Demasiadas peticiones", {
        status: 429,
        headers: { "Retry-After": "60", "Cache-Control": "no-store" },
      });
    }

    return new Response("ok");
  },
} satisfies ExportedHandler<Env>;

El 429 merece cuidado. Acompáñalo siempre de Retry-After, porque es la única forma de que un cliente educado sepa esperar en vez de reintentar en bucle y agravar justo lo que intentabas frenar. Y no reveles en el mensaje cuánto le falta al atacante para volver a pasar.

Elegir la clave

La configuración es lo fácil. La clave es donde está el diseño, porque define a quién estás contando. Una clave demasiado amplia castiga a inocentes; una demasiado estrecha no detiene a nadie.

🌐

Por IP

Lo único que tienes ante un anónimo. Pero una oficina entera, una universidad o una red móvil comparten dirección: limitar por IP es limitar por edificio.

👤

Por usuario

En cuanto hay sesión, la identidad es mejor clave que la red. Un usuario con cinco dispositivos sigue siendo un usuario, y su límite viaja con él.

🎯

Por endpoint

No todo cuesta lo mismo. El login, el envío de correo y la generación con IA merecen espacios y números propios, no un cupo compartido.

🧩

Por composición

La clave real suele ser una mezcla: identidad si la hay, red si no la hay, más la operación. Componer es lo que evita los dos errores opuestos.

function clave(request: Request, usuario: { id: string } | null, operacion: string): string {
  const ip = request.headers.get("CF-Connecting-IP") ?? "sin-ip";
  const sujeto = usuario ? `u:${usuario.id}` : `ip:${ip}`;
  return `${operacion}:${sujeto}`;
}

const login = await env.LIMITE_LOGIN.limit({ key: clave(request, null, "login") });
const api = await env.LIMITE_API.limit({ key: clave(request, usuario, "api") });

Mantén las claves cortas y derivadas de datos que tú controlas. Una clave construida con una cabecera que el cliente escribe libremente no limita a nadie: basta con cambiarla en cada petición para tener un cupo nuevo cada vez.

flowchart TD
P[peticion] --> K[construir la clave]
K --> L[limit sobre el binding]
L -->|success verdadero| H[handler y logica cara]
L -->|success falso| R[429 con Retry After]
style R fill:#f38ba8,color:#11111b
style H fill:#a6e3a1,color:#11111b

Dónde deja de bastar

Un límite aproximado es la herramienta correcta para lo que constituye el noventa por ciento de los casos: contener fuerza bruta en el login, frenar bucles de clientes mal programados, poner un techo al gasto de un endpoint caro y evitar que un solo actor degrade el servicio de todos. En todos esos escenarios, que el corte ocurra en la petición ciento diez en lugar de la cien es irrelevante.

Deja de bastar en el momento en que el número tiene consecuencias contractuales. Si prometes mil llamadas al mes en un plan de pago, si el límite decide una factura o si superarlo tiene efectos legales, necesitas una cuenta exacta, y eso significa un Durable Object por cliente o una contabilidad en el almacén con su reconciliación posterior. Ahí el salto de red que antes era un defecto se convierte en el precio justo de la exactitud.

Lo interesante es que ambos mundos se componen bien, y componerlos es casi siempre mejor que elegir. El binding nativo va delante como filtro barato y absorbe el noventa y nueve por ciento del tráfico abusivo sin coordinar con nadie; solo lo que lo supera llega al contador exacto, que ahora recibe un caudal manejable en lugar de la ráfaga entera. La aproximación protege a la exactitud, y la exactitud sostiene la promesa.

💡
Instrumenta los rechazos o estarás ciego

Un límite bien elegido es indistinguible de un límite demasiado estricto si no mides. Registra cada rechazo con su clave, su operación y su ubicación, y observa la distribución: si una minoría diminuta de claves concentra casi todos los rechazos, el número está bien puesto y estás frenando abuso. Si los rechazos se reparten de forma amplia entre muchas claves distintas, no estás conteniendo a nadie, estás estorbando a tus usuarios.

El limitador honesto es el que te dice cuánto miente

Todo mecanismo de límite de tasa es una respuesta a una pregunta que rara vez se formula en voz alta: cuánta exactitud estás dispuesto a comprar y con qué moneda. Las monedas disponibles son solo dos, latencia y disponibilidad, y no hay una tercera; el teorema que gobierna los sistemas distribuidos se aplica a un contador con la misma indiferencia con que se aplica a una base de datos. Un contador global exacto exige coordinación, la coordinación exige un punto de encuentro, y ese punto es simultáneamente el lugar más lento del sistema y el único cuya caída lo detiene todo. El diseño del binding nativo es interesante precisamente porque no disimula ese dilema con vocabulario tranquilizador: te ofrece un contador que solo sabe lo que ocurre a su alrededor, y te lo dice de frente, en lugar de venderte una garantía global que en la práctica se rompería bajo la carga en la que más la necesitas. Esa honestidad reordena la pregunta del ingeniero. Ya no es cuál es el limitador correcto, sino qué papel juega el número en tu producto. Si el número es una barandilla, algo que impide que un actor cualquiera arruine la experiencia de los demás o vacíe tu presupuesto de inferencia, entonces la aproximación no es una carencia sino la elección óptima, porque una barandilla que cuesta cero microsegundos y no puede caerse vale infinitamente más que una perfecta que añade un salto transatlántico a cada petición del mundo. Si el número es una promesa que aparece en un contrato, entonces aproximar es mentirle al cliente, y toca pagar la coordinación con su latencia y su fragilidad, con los ojos abiertos. La madurez no consiste en preferir siempre lo exacto ni siempre lo barato: consiste en saber en cuál de los dos mundos vive cada límite que escribes, y en no confundir nunca una barandilla con un contrato.

⚔️ Pon un techo y mide dónde se rompe
  1. Declara dos espacios de límite con números distintos, uno estrecho para el login y otro amplio para la API, y aplícalos en sus rutas.
  2. Devuelve un 429 con Retry-After y comprueba desde un cliente que respeta la cabecera frente a otro que reintenta de inmediato. Compara el efecto sobre tu servicio.
  3. Cambia la clave de IP a identidad de usuario cuando haya sesión, y razona qué usuarios legítimos dejaban de ser castigados con el cambio.
  4. Construye deliberadamente una clave a partir de una cabecera que el cliente controla y demuestra que el límite se puede eludir variándola.
  5. Provoca una ráfaga desde dos regiones distintas contra el mismo endpoint y observa que el total admitido supera al configurado. Explica por qué, y en qué caso de tu producto eso sería inaceptable.