Skip to main content
zerotal

Rate Limiting

Rate limiting protects routes from abuse by counting requests per actor and rejecting anything over the threshold. Zerotal ships two complementary limiters in @zerotal/core — both built in, with nothing to install or register.

  • ThrottleMiddleware — a quick inline limiter you attach to a route or the global pipeline.
  • RateLimiter — a named, reusable limiter you define once and apply by name, with rich keying strategies and runtime inspection.

Both use an in-memory sliding window and respond with 429 Too Many Requests, setting Retry-After and X-RateLimit-* headers when the limit is exceeded.

Getting Started

Rate limiting is built into @zerotal/core — nothing to install:

import { ThrottleMiddleware } from "zerotal";

Which should I use?

  • Reach for ThrottleMiddleware for a one-off limit on a single route or the global pipeline — the options live right where you attach it.
  • Reach for RateLimiter when the same limit is reused across many routes, or when you want to query and reset it at runtime (e.g. clearing failed-login counters after a successful sign-in).

ThrottleMiddleware — inline

Attach it directly with the static .with(options) factory, which returns a ready-to-use middleware class:

// in routes/web.ts (or wherever you register routes)
import { ThrottleMiddleware } from "zerotal";

// Global: 120 requests / minute per IP
app.use([ThrottleMiddleware.with({ maxAttempts: 120, windowSeconds: 60 })]);

// Per-route: 5 login attempts / minute
Router.post("/login", AuthController, "login", [
  ThrottleMiddleware.with({ maxAttempts: 5, windowSeconds: 60 }),
]);

// Key by authenticated user instead of IP
ThrottleMiddleware.with({
  maxAttempts: 1000,
  windowSeconds: 3600,
  keyResolver: (ctx) => String(ctx.user?.id ?? ctx.ip()),
});
OptionRequiredDefaultDescription
maxAttemptsyesMax requests within the window.
windowSecondsno60Window length in seconds.
keyResolvernoclient IPFunction returning the rate-limit key for a request.
trustedProxiesnoundefinedNumber of trusted upstream proxies (see the warning below).

Danger — With trustedProxies left undefined, the limiter trusts the leftmost X-Forwarded-For entry, which a client behind a real proxy can forge to dodge the limit. Set trustedProxies to the exact number of proxies in front of your server (0 when there is none) so the real client IP is used.

RateLimiter — named limiters

Define a limiter once (typically in a ServiceProvider.onBooted()), then apply it by name anywhere. Definitions are fluent, and .register() activates them:

// in a ServiceProvider's onBooted()
import { RateLimiter } from "zerotal";

// 1000 req/hour per authenticated user (falls back to IP when unauthenticated)
RateLimiter.for("api").limit(1000).every(3600).byUser().register();

// 5 login attempts per minute, per IP
RateLimiter.for("login").limit(5).every(60).byIp().register();

// 500 req/min keyed by an API-key header (unknown key → per IP)
RateLimiter.for("partner").limit(500).every(60).byApiKey("x-api-key").register();

// Custom key
RateLimiter.for("upload")
  .limit(10)
  .every(3600)
  .by((ctx) => `user:${ctx.user?.id ?? "anon"}`)
  .register();

Warning.register() is required. A definition that is never registered cannot be resolved by RateLimiter.middleware(), which throws if the name is unknown.

Keying strategies

Each .by*() call sets how requests are bucketed. The default (no .by*() call) is the client IP.

MethodKeys onFalls back to
.byUser()ctx.user.idIP when unauthenticated
.byApiKey(header?)x-api-key header (or a custom header)IP when header is absent
.byIp()Socket IP → X-Forwarded-ForX-Real-IP'unknown'
.by(fn)Return value of your function

Applying a named limiter

RateLimiter.middleware(name) returns the middleware instance for a registered limiter, ready to drop into a route or group:

// in routes/web.ts
import { RateLimiter } from "zerotal";

Router.post("/login", AuthController, "login", [RateLimiter.middleware("login")]);

Router.group({ prefix: "/api", middleware: [RateLimiter.middleware("api")] }, () => {
  Router.get("/users", UserController, "index");
});

Inspecting and resetting at runtime

Check or clear a limiter imperatively — e.g. reset failed-login counts after a successful sign-in:

// in a controller
import { RateLimiter } from "zerotal";

if (await RateLimiter.tooManyAttempts("login", ctx)) {
  return ctx.json({ message: "Too Many Requests" }, 429);
}

RateLimiter.resetFor("login", ctx); // clear this actor's counter

NoteRateLimiter.tooManyAttempts() records a hit and returns a promise, so await it. Unlike the middleware, it never sends the 429 itself — you decide how to respond.

Response on limit

When the window is exceeded, both limiters return 429 with:

  • Retry-After — seconds until the window resets.
  • X-RateLimit-Limit / X-RateLimit-Remaining — the cap and what's left.
  • X-RateLimit-Reset — the unix timestamp (seconds) when the window resets.

The 429 body is content-negotiated: an HTML page for web requests, a { message: "Too Many Requests" } JSON object for API requests, and a plain-text line for CLI requests.

Warning — Counters are in-memory and live in the process. With multiple instances behind a load balancer, each enforces the limit independently — fine for coarse protection. For a hard global cap across instances, gate the action with a shared store such as a distributed lock or a cache-backed counter.

Testing

Set your suite up once as described in Testing. A rate limiter is only proven by the request that gets refused, so the test has to exhaust it.

// tests/http/throttle.test.ts
import { test, expect } from "bun:test";
import { createApp } from "../helpers.ts";

test("the sixth attempt in a minute is refused", async () => {
  const app = await createApp();

  for (let i = 0; i < 5; i++) {
    (await app.post("/login", { email: "a@b.c", password: "wrong" })).assertStatus(422);
  }

  const blocked = await app.post("/login", { email: "a@b.c", password: "wrong" });

  blocked.assertStatus(429);
  blocked.assertHeader("Retry-After");
  await app.close();
});

Assert the headers, not just the status. X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset are what a well-behaved client reads to back off. A limiter that returns 429 without them still fails the clients it was meant to protect you from:

// tests/http/throttle.test.ts
blocked.assertHeader("X-RateLimit-Limit", "5");
blocked.assertHeader("X-RateLimit-Remaining", "0");

The counter is shared state, so a limiter test contaminates whatever runs next in the same window. Give each test a distinct key — a different route, IP header, or user — rather than relying on ordering:

// tests/http/throttle.test.ts
await app.post("/login", { email: "a@b.c" }, { "X-Forwarded-For": "10.0.0.7" });

Warning — A throttled response is 429 for JSON and an HTML page for a browser request. assertStatus(429) holds for both; assertJson() does not.

References

RateLimiter (static)

MethodSignatureDescription
forfor(name: string): LimiterDefinitionBegin a fluent definition.
middlewaremiddleware(name: string): ThrottleMiddlewareGet middleware for a registered limiter (throws if unknown).
tooManyAttemptstooManyAttempts(name: string, ctx: HttpContext): Promise<boolean>Record a hit; true if the actor is over the limit.
resetForresetFor(name: string, ctx: HttpContext): voidReset the actor's counter for a limiter.
clearclear(): voidClear all registered limiters (useful in tests).

LimiterDefinition (fluent)

MethodSignatureDescription
limitlimit(max: number): thisMaximum requests in the window (default 60).
everyevery(seconds: number): thisWindow duration in seconds (default 60).
byUserbyUser(): thisKey by ctx.user.id; IP when unauthenticated.
byApiKeybyApiKey(header?: string): thisKey by header value (default x-api-key); IP when absent.
byIpbyIp(): thisKey by client IP (the explicit default).
byby(fn: (ctx: HttpContext) => string): thisKey by your own resolver.
registerregister(): thisRegister the limiter with the global registry.

ThrottleMiddleware

MemberSignatureDescription
withstatic with(options: Partial<ThrottleOptions>): new () => ThrottleMiddlewareBuild a middleware class from options.
resetreset(): voidClear all counters (useful in tests).
resetKeyresetKey(ctx: HttpContext): voidClear the counter for one context's key.

Next steps

  • Middleware — attaching middleware to routes and groups.
  • Lock — coordinating limits across multiple instances.
  • Authenticationctx.user used by .byUser().