soul v4.2.0

@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:

TypeScript
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#

TypeScript
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.