Skip to content

Rate Limiting

End-to-end rate limiting: L1 global-IP + L2 auth + L3 handler opt-in.

  • Handler rateLimit: { per, limit, windowSeconds }
  • How L1/L2 from buildServer stack with L3

A public write handler that mails an address typed by the caller is also limited per address, so rotating IPs does not help against mail flooding. Declare it next to rateLimit:

r.writeHandler({
// ...
access: { roles: ["anonymous"] },
rateLimit: { per: "ip+handler", limit: 5, windowSeconds: 600 },
additionalRateLimits: [{ per: { payloadField: "email" }, limit: 3, windowSeconds: 86400 }],
});

additionalRateLimits only complements rateLimit: an anonymous handler still needs a real, ip-keyed rateLimit (boot fails otherwise), and payloadField must be a top-level string field of the handler’s Zod object schema. The value is trimmed, lowercased and HMAC-hashed into the bucket key, so Redis never holds the address in plaintext. The check runs after schema validation and before the handler, identically for matching and non-matching values, and is skipped for system callers. Exceeding it answers 429 rate_limited.

Feature entry point: src/feature.ts.

Needs a running Postgres and TEST_DATABASE_URL set (e.g. postgres://postgres:[email protected]:5432/postgres, see demo/.env.example).

Terminal window
cd samples/recipes/rate-limiting
bun test

The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):

// Rate-Limiting Showcase — minimal feature
//
// Declares one query handler with an L3 rateLimit option so the
// dispatcher gates calls before the handler body runs. The integration
// test pairs this with L1+L2 middleware wired via buildServer's
// `rateLimit` option to prove all three layers stack.
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
import { z } from "zod";
export function createRateLimitShowcaseFeature(): FeatureDefinition {
return defineFeature("rl-showcase", (r) => {
// Per-user budget. Real apps tune `limit` to actual handler cost — a
// search call against a sharded index might warrant 5/min, a full
// export 1/min. The bucket is `user:<userId>` (see rate-limit/bucket.ts).
r.queryHandler(
"expensive-search",
z.object({ q: z.string().min(1) }),
async ({ payload }) => ({ q: payload.q, hits: 0 }),
{
access: { roles: ["Admin", "User"] },
rateLimit: { per: "user", limit: 3, windowSeconds: 60 },
},
);
});
}

📄 On GitHub: samples/recipes/rate-limiting/src/feature.ts