Rate Limiting
Introduction
Rate limiting caps how often a key may succeed inside a fixed time window. Use it for login attempts, API traffic, or any in-process action that should not run unbounded.
The API lives in @bunyad/http:
import {
RateLimiter,
Limit,
getRateLimiter,
setRateLimiter,
registerRateLimitPresets,
throttle,
} from "@bunyad/http";HttpServiceProvider creates a RateLimiter, registers the built-in named limiters (api, auth, tokens), and calls setRateLimiter. Counters are an in-memory map on that instance. They reset when the process exits and are not shared across workers.
A minimal app that does not load the provider wires the limiter itself:
import {
RateLimiter,
registerRateLimitPresets,
setRateLimiter,
} from "@bunyad/http";
const limiter = new RateLimiter();
registerRateLimitPresets(limiter);
setRateLimiter(limiter);Basic usage
Resolve the shared limiter with getRateLimiter(). attempt records one hit and returns a RateLimitResult:
import { getRateLimiter } from "@bunyad/http";
const limiter = getRateLimiter();
const result = limiter.attempt(`send-message:${userId}`, 5, 60);
if (!result.allowed) {
return `Too many messages. Try again in ${result.retryAfter} seconds.`;
}
// Send the message…Arguments are the key, the maximum attempts, and the window length in seconds. The result shape:
type RateLimitResult = {
allowed: boolean;
limit: number;
remaining: number;
retryAfter: number; // seconds until reset when blocked; 0 when allowed
resetAt: number; // epoch milliseconds when the window ends
};Manual hits
Check without always combining the write, or drive the counter yourself:
const key = `send-message:${userId}`;
const perMinute = 5;
if (limiter.tooManyAttempts(key, perMinute)) {
const seconds = limiter.availableIn(key);
return `You may try again in ${seconds} seconds.`;
}
limiter.hit(key, 60);
// Send the message…hit(key, decaySeconds?) increments and returns the new attempt count (default decay 60 seconds). increment(key, decaySeconds?, amount?) calls hit repeatedly. decrement lowers the count. attempts / remaining / retriesLeft read the current window. availableAt returns a Unix timestamp (seconds). availableIn returns seconds until the window resets.
if (limiter.remaining(key, perMinute) > 0) {
limiter.increment(key);
// …
}
limiter.clear(key); // same as resetAttempts(key)
limiter.flush(); // clear every keycleanRateLimiterKey strips & and : and truncates to 200 characters when you want a safe storage key.
Defining rate limiters
Named limiters are callbacks registered with for. Each callback receives the HTTP Request (or another context object) and returns a Limit or an array of Limits:
import { getRateLimiter, Limit } from "@bunyad/http";
import type { Request } from "@bunyad/http";
const limiter = getRateLimiter();
limiter.for("uploads", (request: Request) => {
return Limit.perMinute(100).by(request.ip());
});Build limits with:
| Builder | Window |
|---|---|
Limit.perMinute(n) |
n attempts per 60 seconds |
Limit.perSeconds(n, seconds) |
n attempts per seconds |
Limit.perHour(n) |
n attempts per 3600 seconds |
Chain by(key) to segment the counter (IP, user id, email, and so on):
limiter.for("uploads", (request: Request) => {
const user = request.user as { id?: number } | undefined;
return user?.id
? Limit.perMinute(100).by(user.id)
: Limit.perMinute(10).by(request.ip());
});Multiple limits
Return an array. Every limit is evaluated in order. The first that blocks wins:
limiter.for("login", (request: Request) => {
const email = String(request.input("email") ?? request.ip());
return [
Limit.perMinute(500).by(request.ip()),
Limit.perMinute(3).by(`email:${email}`),
];
});When two segments would collide, prefix the by value so the keys stay unique:
limiter.for("uploads", (request: Request) => {
const id = (request.user as { id: number }).id;
return [
Limit.perMinute(10).by(`minute:${id}`),
Limit.perHour(1000).by(`hour:${id}`),
];
});Built-in presets
registerRateLimitPresets registers three names:
| Name | Rule |
|---|---|
api |
60 per minute by authenticated user id, or IP |
auth |
5 per minute by IP (login / register) |
tokens |
10 per hour by IP (token endpoints) |
Limit.api, Limit.auth, and Limit.tokens are the same builders if you call them yourself:
import { Limit } from "@bunyad/http";
const limit = Limit.api(request); // Limit.perMinute(60).by(user id or IP)Look up a registered callback with limiter.limiter("api").
Attaching limiters to routes
Use the throttle middleware from @bunyad/http. Pass a max attempts count and a decay window in minutes (default one minute). The default key is the client IP:
import { Route } from "@bunyad/router";
import { throttle } from "@bunyad/http";
Route.middleware(throttle(60, 1)).group(() => {
Route.post("/login", () => "ok");
});Pass a string to use a named limiter:
Route.middleware(throttle("auth")).group(() => {
Route.post("/login", [AuthController, "login"]);
});String aliases work the same way once throttle is registered (the HTTP package registers it on import):
Route.middleware("throttle:60,1").post("/search", handler);
Route.middleware("throttle:auth").post("/login", handler);
Route.middleware("throttle").get("/api/me", handler); // named limiter "api"Custom key or an explicit limiter instance:
Route.middleware(
throttle(30, 1, {
key: (request) => String(request.input("email") ?? request.ip()),
}),
).post("/password/email", handler);The web starter kits lock login out inside the login request itself: five failures per email and IP per minute, counted with RateLimiter.hit and cleared with RateLimiter.clear on success, so a correct password never uses up the budget. They throttle the two-factor challenge and verification mail with throttle:5,1 / throttle:6,1. The API starter uses throttle("tokens") around register, a token limiter that counts only failed token requests, and throttle("api") around authenticated routes.
Response when blocked
A blocked request returns HTTP 429 with JSON { "message": "Too Many Attempts." } and these headers:
Retry-After— seconds until the window resetsX-RateLimit-Limit— max attemptsX-RateLimit-Remaining—0
Allowed responses get X-RateLimit-Limit and X-RateLimit-Remaining added to the downstream response.
Evaluating a named limiter yourself
Outside middleware, call attemptNamed:
const result = await getRateLimiter().attemptNamed("api", request);
if (!result.allowed) {
return new Response(JSON.stringify({ message: "Too Many Attempts." }), {
status: 429,
headers: { "Retry-After": String(result.retryAfter) },
});
}Missing names throw: Rate limiter [name] is not defined.