@jun/soul/ratelimit
Rate limit
Ventana fija sobre D1: un UPSERT atomico por request.
Que resuelve#
Poner un techo a cuantas veces se puede llamar a un endpoint. Es lo que separa un formulario publico de un buzon de spam, y una API de activacion de un oraculo para adivinar claves por fuerza bruta.
Por que D1 y no la Cache API#
La API#
rateLimit(db, { scope, key, limit, windowSeconds?, now? })- Cuenta la request y dice si sigue dentro del limite. La ventana por defecto es 60 segundos.
cleanupRateLimits(db, olderThanSeconds = 3600)- Borra las ventanas cerradas. Va en el cron.
clientIp(headers)- Lee
cf-connecting-ip, o"unknown".
Lo que devuelve:
const r = await rateLimit(db, { scope: "login", key: ip, limit: 10 });
r.ok; // false => responde 429
r.count; // cuantas van en esta ventana, incluida la actual
r.limit; // el limite que le pasaste
r.retryAfterSeconds; // segundos reales hasta que se reinicia
scope y key#
scope separa contadores por endpoint ("login", "activate") y key
identifica al cliente (una IP, un email, un id de usuario). El bucket combina ambos con el numero de
ventana, asi que expira solo: no hace falta borrar nada para que el contador se reinicie.
Uso tipico#
import { rateLimit, clientIp } from "@jun/soul/ratelimit";
app.post("/api/activate", async (c) => {
const r = await rateLimit(c.env.DB, {
scope: "activate",
key: clientIp(c.req.raw.headers),
limit: 10,
});
if (!r.ok) {
const res = c.json({ error: "Demasiadas solicitudes." }, 429);
// Segundos reales que faltan, no un valor fijo.
res.headers.set("Retry-After", String(r.retryAfterSeconds));
return res;
}
// ...
});
Migracion#
Requiere 0005_soul_ratelimit.sql (npx soul sync-migrations), que crea la tabla
rate_limits. No lleva org_id: es infraestructura de plataforma, no datos de inquilino.
Helper reutilizable#
Si tienes varios endpoints publicos, conviene un guard que ya devuelva la respuesta:
import { rateLimit, clientIp } from "@jun/soul/ratelimit";
async function limitar(c: Context<{ Bindings: Env }>, scope: string, limit: number) {
const r = await rateLimit(c.env.DB, { scope, key: clientIp(c.req.raw.headers), limit });
if (r.ok) return null;
const res = c.json({ error: { code: "rate_limited" } }, 429);
res.headers.set("Retry-After", String(r.retryAfterSeconds));
return res;
}
// Un limite por endpoint, segun lo caro o abusable que sea.
v1.post("/activate", async (c) => {
const cortado = await limitar(c, "activate", 10);
if (cortado) return cortado;
// ...
});
v1.post("/validate", async (c) => {
const cortado = await limitar(c, "validate", 60); // se llama seguido: mas holgado
if (cortado) return cortado;
// ...
});
v1.post("/trial/start", async (c) => {
const cortado = await limitar(c, "trial", 5); // crea filas sin credencial: bajo
if (cortado) return cortado;
// ...
});
Limitar por email, no por IP#
Para un codigo al email, la IP no es la unidad correcta: una oficina entera comparte IP. El email si.
const limite = await rateLimit(c.env.DB, {
scope: "portal-otp",
key: email,
limit: 3,
windowSeconds: 3600, // tres codigos por hora
});
if (!limite.ok) return c.html(errorPage("Demasiados intentos", "Prueba mas tarde."), 429);
Dos capas: por IP y por cuenta#
// Capa 1: nadie desde una IP puede intentar mas de 20 veces por minuto.
const porIp = await rateLimit(c.env.DB, {
scope: "login-ip", key: clientIp(c.req.raw.headers), limit: 20,
});
if (!porIp.ok) return c.json({ error: "rate_limited" }, 429);
// Capa 2: una cuenta concreta, mas estricta y con ventana mas larga
// (frena el ataque distribuido contra un solo usuario).
const porCuenta = await rateLimit(c.env.DB, {
scope: "login-account", key: email, limit: 5, windowSeconds: 900,
});
if (!porCuenta.ok) return c.json({ error: "rate_limited" }, 429);
Probarlo con reloj fijo#
El parametro now existe para los tests: sin el, una prueba que cruce el borde de la
ventana falla de vez en cuando y nadie sabe por que.
import { rateLimit } from "@jun/soul/ratelimit";
it("corta al pasarse del limite", async () => {
const now = 1_700_000_000; // reloj fijo: la ventana no se mueve
for (let i = 0; i < 3; i++) {
const r = await rateLimit(env.DB, { scope: "t", key: "k", limit: 3, now });
expect(r.ok).toBe(true);
}
const cuarta = await rateLimit(env.DB, { scope: "t", key: "k", limit: 3, now });
expect(cuarta.ok).toBe(false);
expect(cuarta.count).toBe(4);
});
it("la ventana siguiente empieza limpia", async () => {
const now = 1_700_000_000;
await rateLimit(env.DB, { scope: "t", key: "k2", limit: 1, now });
const bloqueada = await rateLimit(env.DB, { scope: "t", key: "k2", limit: 1, now });
expect(bloqueada.ok).toBe(false);
// +60s => otro bucket
const siguiente = await rateLimit(env.DB, { scope: "t", key: "k2", limit: 1, now: now + 60 });
expect(siguiente.ok).toBe(true);
});
let ipCounter = 0;
function post(path: string, body: unknown, ip?: string) {
return SELF.fetch(`https://example.com${path}`, {
method: "POST",
headers: { "Content-Type": "application/json", "cf-connecting-ip": ip ?? `10.0.0.${++ipCounter}` },
body: JSON.stringify(body),
});
}
Limpieza en el cron#
export const scheduled = async (event: ScheduledEvent, env: SoulEnv) => {
await cleanupRateLimits(env.DB);
};
// wrangler.jsonc — recuerda repetirlo en cada env con nombre
{ "triggers": { "crons": ["0 4 * * *"] } }