@tetsujs/rate-limit refuses requests from a client that goes over its budget
for a window of time. It is a hook you mount where the limit applies, and the
counters live in a store you can replace.
Terminal window
bunadd@tetsujs/rate-limit
It depends on @tetsujs/openapi, which it uses to
document the refusal.
Hits allowed per window: an integer, 0 included — a limiter that
refuses everything.
limit: 60,
windowMs: number
Length of the window in milliseconds: a positive, finite number.
Anything else is refused when the limiter is made. NaN — what
Number(process.env.RATE_WINDOW) reads as when the variable is not
set — and 0 used to start a new window on every request, so nothing
was ever refused while the headers went on reporting a budget.
windowMs: 60_000,
key: (ctx) => ctx.
server: Bun.Server<unknown>
The server handling this request — the real Bun.Server, whole and
unguarded, like req an escape hatch to the platform.
The way to reach connection- and server-level facts a Request does
not carry: ctx.server.requestIP(ctx.req) for rate limiting by
address, ctx.server.timeout(ctx.req, seconds) for a per-request idle
timeout.
An address is in the form the socket reports it: a server listening on
both stacks — Bun's default — reports an IPv4 client as
::ffff:203.0.113.7, which a comparison with 203.0.113.7 misses.
Deliberately not a facade: every hook is code the
application author wrote or vetted, and hiding stop/reload from
in-process code protects nothing. They are still process-level
operations with no business inside a request — calling
ctx.server.stop() from a hook takes the whole listener down.
upgrade is for the endpoints ws() declares, which the application
upgrades itself. A socket upgraded by hand reaches the application's
websocket handler with no endpoint on it: the connection drops, and
each of its events is reported as a failure.
server.requestIP(ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}
The raw incoming request, always available as an escape hatch.
On a request that matched a route, Bun's router delivers its own
request object carrying cookies — a Bun.CookieMap whose mutations
are applied to the response as Set-Cookie automatically, with Bun's
defaults (Path=/; SameSite=Lax). The field is optional because it
does not exist where Bun's router was not involved: the 404 fallback
and unit-tested handlers. ctx.out.headers.append("set-cookie", ...)
is the fallback that works everywhere.
This allows 60 requests a minute per client address. Behind a proxy that
address is the proxy’s: see Behind a proxy.
A request over the limit gets 429 with the standard error body (code
RATE_LIMITED, plus retryAfter in seconds) and a retry-after header. The
refusal is a thrown HttpError, so onError hooks and a custom error format
apply to it. Every counted response carries x-ratelimit-limit,
x-ratelimit-remaining and x-ratelimit-reset (seconds until the window
ends).
rateLimit() is one beforeParse hook. To limit one route, mount it on the
route:
constfeedback=route({
method: "POST"
The method this route answers.
Kept as a literal rather than widened to
Method
: the method is
half of a route's identity, and an application that remembers its
routes — for a generated client, for tooling — needs to know which one
this is.
method: "POST",
path: "/feedback"
Route path with :param segments, e.g. "/orders/:id/cancel".
Must start with /, contain no empty segments and no trailing slash;
a malformed literal is a compile error.
path: "/feedback",
hooks: { beforeParse: [limit] },
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) => {
received: boolean;
}
The endpoint logic; ctx is fully inferred, never annotate it.
The return type is inferred rather than demanded, and checked twice
over. Against the route's own contract, by the
HandlerResult
bound on R: answering with something response never declared is a
compile error that says so, instead of a structural diff against
Response. And against what the framework can serialize at all, by
the intersected
ValidateResult
: a stream handed over bare is
refused whether or not the route declared anything, because that is
the case no contract covers — without a response schema
HandlerResult is unknown and accepts every value there is.
One limiter is one budget: mounted on two routes, it counts both together. For
separate budgets, make two limiters.
Two limiters on one route work too, such as a short window against bursts and
a long one. A request passes when it is within both. Give one of them
headers: false, or the two overwrite each other’s x-ratelimit-* headers:
constburst=rateLimit({
limit: number
Hits allowed per window: an integer, 0 included — a limiter that
refuses everything.
limit: 3,
windowMs: number
Length of the window in milliseconds: a positive, finite number.
Anything else is refused when the limiter is made. NaN — what
Number(process.env.RATE_WINDOW) reads as when the variable is not
set — and 0 used to start a new window on every request, so nothing
was ever refused while the headers went on reporting a budget.
windowMs: 1_000,
key: (ctx: {
server:Bun.Server<unknown>;
req:Request;
}) => string |undefined
key });
consthourly=rateLimit({
limit: number
Hits allowed per window: an integer, 0 included — a limiter that
refuses everything.
limit: 20,
windowMs: number
Length of the window in milliseconds: a positive, finite number.
Anything else is refused when the limiter is made. NaN — what
Number(process.env.RATE_WINDOW) reads as when the variable is not
set — and 0 used to start a new window on every request, so nothing
was ever refused while the headers went on reporting a budget.
windowMs: 3_600_000,
key: (ctx: {
server:Bun.Server<unknown>;
req:Request;
}) => string |undefined
key,
headers?: boolean |undefined
Whether every response carries x-ratelimit-*.
On by default: a client that cannot see its budget can only discover
it by being refused.
headers: false });
constlogin=route({
method: "POST"
The method this route answers.
Kept as a literal rather than widened to
Method
: the method is
half of a route's identity, and an application that remembers its
routes — for a generated client, for tooling — needs to know which one
this is.
method: "POST",
path: "/login"
Route path with :param segments, e.g. "/orders/:id/cancel".
Must start with /, contain no empty segments and no trailing slash;
a malformed literal is a compile error.
path: "/login",
hooks: { beforeParse: [burst, hourly] },
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) => {
ok: boolean;
}
The endpoint logic; ctx is fully inferred, never annotate it.
The return type is inferred rather than demanded, and checked twice
over. Against the route's own contract, by the
HandlerResult
bound on R: answering with something response never declared is a
compile error that says so, instead of a structural diff against
Response. And against what the framework can serialize at all, by
the intersected
ValidateResult
: a stream handed over bare is
refused whether or not the route declared anything, because that is
the case no contract covers — without a response schema
HandlerResult is unknown and accepts every value there is.
handler: () => ({
ok: boolean
ok: true }),
});
perRoute: true gives each route its own budget from one limiter mounted on
the application, for “20 a minute on every endpoint”:
import { rateLimit } from"@tetsujs/rate-limit";
consteach=rateLimit({
perRoute?: boolean |undefined
Whether each route has a budget of its own.
Off by default: one limiter is one budget, across every route it is
mounted on — "100 a minute for the whole API". On, it is one budget
per route and client — "20 a minute on each endpoint" — from a single
limiter on the application or a group.
A route is its template, GET /orders/:id, so every order shares
one. Requests no route answers — a 404, a 405, a CORS preflight —
share one budget between them, so probing paths that do not exist is
counted too; cors() before the limiter answers a preflight first.
perRoute: true,
limit: number
Hits allowed per window: an integer, 0 included — a limiter that
refuses everything.
limit: 20,
windowMs: number
Length of the window in milliseconds: a positive, finite number.
Anything else is refused when the limiter is made. NaN — what
Number(process.env.RATE_WINDOW) reads as when the variable is not
set — and 0 used to start a new window on every request, so nothing
was ever refused while the headers went on reporting a budget.
windowMs: 60_000,
key: (ctx) => ctx.
server: Bun.Server<unknown>
The server handling this request — the real Bun.Server, whole and
unguarded, like req an escape hatch to the platform.
The way to reach connection- and server-level facts a Request does
not carry: ctx.server.requestIP(ctx.req) for rate limiting by
address, ctx.server.timeout(ctx.req, seconds) for a per-request idle
timeout.
An address is in the form the socket reports it: a server listening on
both stacks — Bun's default — reports an IPv4 client as
::ffff:203.0.113.7, which a comparison with 203.0.113.7 misses.
Deliberately not a facade: every hook is code the
application author wrote or vetted, and hiding stop/reload from
in-process code protects nothing. They are still process-level
operations with no business inside a request — calling
ctx.server.stop() from a hook takes the whole listener down.
upgrade is for the endpoints ws() declares, which the application
upgrades itself. A socket upgraded by hand reaches the application's
websocket handler with no endpoint on it: the connection drops, and
each of its events is reported as a failure.
server.requestIP(ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}
The raw incoming request, always available as an escape hatch.
On a request that matched a route, Bun's router delivers its own
request object carrying cookies — a Bun.CookieMap whose mutations
are applied to the response as Set-Cookie automatically, with Bun's
defaults (Path=/; SameSite=Lax). The field is optional because it
does not exist where Bun's router was not involved: the 404 fallback
and unit-tested handlers. ctx.out.headers.append("set-cookie", ...)
is the fallback that works everywhere.
A route is its template, so /orders/1 and /orders/2 share
GET /orders/:id. Requests no route answers (a 404, a 405, an OPTIONS
preflight) share one budget, so probing for paths is counted too. With
cors() mounted before the limiter, a preflight is
answered before it is counted.
key decides what is counted, and there is no default. The right key depends
on the deployment: behind a balancer, a default by address would put every
client in one bucket while the limiter looked fine. Return a string, or
undefined to skip the limit for that request.
A key must be something the client cannot choose. An unverified cookie,
token or header is whatever the client sends, and a new value each time means
a new, empty budget each time. Count by the connection’s address, or by what a
hook has verified.
By default the key runs before the body is parsed, so it reads the request
itself and what earlier beforeParse hooks returned. To read a field another hook
provides, declare it with Requires:
Hits allowed per window: an integer, 0 included — a limiter that
refuses everything.
limit: 100,
windowMs: number
Length of the window in milliseconds: a positive, finite number.
Anything else is refused when the limiter is made. NaN — what
Number(process.env.RATE_WINDOW) reads as when the variable is not
set — and 0 used to start a new window on every request, so nothing
was ever refused while the headers went on reporting a budget.
windowMs: 60_000,
key: (ctx:Requires<{
userId:string;
}>) => string
key: (
ctx: Requires<{
userId:string;
}>
ctx:Requires<{
userId: string
userId:string }>) =>
ctx: Requires<{
userId:string;
}>
ctx.
userId: string
userId,
});
constcreateOrder=route({
method: "POST"
The method this route answers.
Kept as a literal rather than widened to
Method
: the method is
half of a route's identity, and an application that remembers its
routes — for a generated client, for tooling — needs to know which one
this is.
method: "POST",
path: "/orders"
Route path with :param segments, e.g. "/orders/:id/cancel".
Must start with /, contain no empty segments and no trailing slash;
a malformed literal is a compile error.
path: "/orders",
hooks: { beforeParse: [auth, perUser] },
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
userId:string;
}) => {
owner: string;
}
The endpoint logic; ctx is fully inferred, never annotate it.
The return type is inferred rather than demanded, and checked twice
over. Against the route's own contract, by the
HandlerResult
bound on R: answering with something response never declared is a
compile error that says so, instead of a structural diff against
Response. And against what the framework can serialize at all, by
the intersected
ValidateResult
: a stream handed over bare is
refused whether or not the route declared anything, because that is
the case no contract covers — without a response schema
HandlerResult is unknown and accepts every value there is.
handler: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
userId: string;
}
ctx) => ({
owner: string
owner:
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
userId: string;
}
ctx.
userId: string
userId }),
});
The limiter then requires userId wherever it is mounted: placed before
auth, or where nothing provides it, it does not compile. See
Context and its types.
signedCookie() reads and verifies a signed cookie in beforeParse, where
ctx.cookies is not filled in yet. Do not key by ctx.req.cookies: it holds
the cookie as the client sent it, unverified.
Behind a proxy, the connection’s address is the proxy’s, and the client’s
is in x-forwarded-for. Count it from the end of that header, by the
number of your own proxies in front: the first entry is whatever the client
sent, and would let it pick its own bucket.
Behind a proxy
shows a hook that works the address out and a limiter keyed by it.
A server that listens on IPv4 and IPv6, Bun.serve’s default, reports an
IPv4 client as ::ffff:203.0.113.7. As a key that is fine. To compare it
with a list of addresses, strip the ::ffff: prefix first, as the next
example does.
Returning undefined skips the limit. Base an exemption on the connection’s
address, not on a header such as x-internal, which any client can send:
const
constinternal:Set<string>
internal=newSet(["10.0.0.5", "10.0.0.6"]);
constlimit=rateLimit({
limit: number
Hits allowed per window: an integer, 0 included — a limiter that
refuses everything.
limit: 60,
windowMs: number
Length of the window in milliseconds: a positive, finite number.
Anything else is refused when the limiter is made. NaN — what
Number(process.env.RATE_WINDOW) reads as when the variable is not
set — and 0 used to start a new window on every request, so nothing
was ever refused while the headers went on reporting a budget.
windowMs: 60_000,
key: (ctx) => {
const
constaddress:string|undefined
address= ctx.
server: Bun.Server<unknown>
The server handling this request — the real Bun.Server, whole and
unguarded, like req an escape hatch to the platform.
The way to reach connection- and server-level facts a Request does
not carry: ctx.server.requestIP(ctx.req) for rate limiting by
address, ctx.server.timeout(ctx.req, seconds) for a per-request idle
timeout.
An address is in the form the socket reports it: a server listening on
both stacks — Bun's default — reports an IPv4 client as
::ffff:203.0.113.7, which a comparison with 203.0.113.7 misses.
Deliberately not a facade: every hook is code the
application author wrote or vetted, and hiding stop/reload from
in-process code protects nothing. They are still process-level
operations with no business inside a request — calling
ctx.server.stop() from a hook takes the whole listener down.
upgrade is for the endpoints ws() declares, which the application
upgrades itself. A socket upgraded by hand reaches the application's
websocket handler with no endpoint on it: the connection drops, and
each of its events is reported as a failure.
server.requestIP(ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}
The raw incoming request, always available as an escape hatch.
On a request that matched a route, Bun's router delivers its own
request object carrying cookies — a Bun.CookieMap whose mutations
are applied to the response as Set-Cookie automatically, with Bun's
defaults (Path=/; SameSite=Lax). The field is optional because it
does not exist where Bun's router was not involved: the 404 fallback
and unit-tested handlers. ctx.out.headers.append("set-cookie", ...)
is the fallback that works everywhere.
req)?.
address: string
The IP address of the client.
address.replace(/^::ffff:/, "");
return
constaddress:string|undefined
address&&
constinternal:Set<string>
internal.has(
constaddress:string
address) ?undefined:
constaddress:string|undefined
address;
},
});
Likewise, a key such as header ?? undefined skips the limit for every client
that leaves the header out. A health check needs no exemption: mount the
limiter on the routes or groups it protects, and leave the probes outside.
A limit by address barely slows password guessing: an attacker with many
addresses gets a full budget on each. What works is counting by the account
being tried, and the account is in the body. slot: "beforeHandle" runs the
limiter after the body is validated, so the key can read it:
import { rateLimit } from"@tetsujs/rate-limit";
importtype { Requires } from"@tetsujs/core";
constperAccount=rateLimit({
slot: "beforeHandle"
slot: "beforeHandle",
limit: number
Hits allowed per window: an integer, 0 included — a limiter that
refuses everything.
limit: 5,
windowMs: number
Length of the window in milliseconds: a positive, finite number.
Anything else is refused when the limiter is made. NaN — what
Number(process.env.RATE_WINDOW) reads as when the variable is not
set — and 0 used to start a new window on every request, so nothing
was ever refused while the headers went on reporting a budget.
windowMs: 15*60_000,
key: (ctx:Requires<{
body: {
email:string;
};
}>) => string
key: (
ctx: Requires<{
body: {
email:string;
};
}>
ctx:Requires<{
body: {
email: string;
}
body: {
email: string
email:string } }>) =>
ctx: Requires<{
body: {
email:string;
};
}>
ctx.
body: {
email: string;
}
body.
email: string
email,
});
constperAddress=rateLimit({
limit: number
Hits allowed per window: an integer, 0 included — a limiter that
refuses everything.
limit: 60,
windowMs: number
Length of the window in milliseconds: a positive, finite number.
Anything else is refused when the limiter is made. NaN — what
Number(process.env.RATE_WINDOW) reads as when the variable is not
set — and 0 used to start a new window on every request, so nothing
was ever refused while the headers went on reporting a budget.
windowMs: 60_000,
key: (ctx) => ctx.
server: Bun.Server<unknown>
The server handling this request — the real Bun.Server, whole and
unguarded, like req an escape hatch to the platform.
The way to reach connection- and server-level facts a Request does
not carry: ctx.server.requestIP(ctx.req) for rate limiting by
address, ctx.server.timeout(ctx.req, seconds) for a per-request idle
timeout.
An address is in the form the socket reports it: a server listening on
both stacks — Bun's default — reports an IPv4 client as
::ffff:203.0.113.7, which a comparison with 203.0.113.7 misses.
Deliberately not a facade: every hook is code the
application author wrote or vetted, and hiding stop/reload from
in-process code protects nothing. They are still process-level
operations with no business inside a request — calling
ctx.server.stop() from a hook takes the whole listener down.
upgrade is for the endpoints ws() declares, which the application
upgrades itself. A socket upgraded by hand reaches the application's
websocket handler with no endpoint on it: the connection drops, and
each of its events is reported as a failure.
server.requestIP(ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}
The raw incoming request, always available as an escape hatch.
On a request that matched a route, Bun's router delivers its own
request object carrying cookies — a Bun.CookieMap whose mutations
are applied to the response as Set-Cookie automatically, with Bun's
defaults (Path=/; SameSite=Lax). The field is optional because it
does not exist where Bun's router was not involved: the 404 fallback
and unit-tested handlers. ctx.out.headers.append("set-cookie", ...)
is the fallback that works everywhere.
req)?.
address: string |undefined
The IP address of the client.
address,
});
constlogin=route({
method: "POST"
The method this route answers.
Kept as a literal rather than widened to
Method
: the method is
half of a route's identity, and an application that remembers its
routes — for a generated client, for tooling — needs to know which one
this is.
method: "POST",
path: "/login"
Route path with :param segments, e.g. "/orders/:id/cancel".
Must start with /, contain no empty segments and no trailing slash;
a malformed literal is a compile error.
The endpoint logic; ctx is fully inferred, never annotate it.
The return type is inferred rather than demanded, and checked twice
over. Against the route's own contract, by the
HandlerResult
bound on R: answering with something response never declared is a
compile error that says so, instead of a structural diff against
Response. And against what the framework can serialize at all, by
the intersected
ValidateResult
: a stream handed over bare is
refused whether or not the route declared anything, because that is
the case no contract covers — without a response schema
HandlerResult is unknown and accepts every value there is.
handler: () => ({
ok: boolean
ok: true }),
});
The address limit still refuses a flood before reading its bodies, and the
account limit stops guessing across addresses. A limiter can be made for
beforeParse (the default), beforeValidation or beforeHandle, and the
compiler refuses it in any other slot.
requests allowed per window: a whole number, 0 or more (0 refuses everything)
windowMs
required
window length in milliseconds: positive and finite
key
required
what is counted; undefined skips the limit
perRoute
false
a budget per route rather than one for the limiter
slot
"beforeParse"
"beforeValidation" or "beforeHandle" to count by what the body holds
store
memoryStore()
where the counters live
name
none
required with store, and only with it
status
429
status of a refusal
headers
true
send the x-ratelimit-* headers
Bad options throw when the limiter is made. For example, a windowMs of NaN
(what Number() of an unset environment variable gives) would otherwise
refuse nothing.
The default memoryStore() keeps counters in the process: fine for one server
and for tests, not for several behind a load balancer. A store is one method,
hit(key, windowMs), which counts a hit and returns { count, resetAt }, with
resetAt in epoch milliseconds. It may be async:
import { rateLimit, type RateLimitStore } from"@tetsujs/rate-limit";
What tells this limiter's counters from any other's in the store.
name: "shop-login",
store: RateLimitStore
Where the counters live.
store:
constredisStore:RateLimitStore
redisStore,
limit: number
Hits allowed per window: an integer, 0 included — a limiter that
refuses everything.
limit: 5,
windowMs: number
Length of the window in milliseconds: a positive, finite number.
Anything else is refused when the limiter is made. NaN — what
Number(process.env.RATE_WINDOW) reads as when the variable is not
set — and 0 used to start a new window on every request, so nothing
was ever refused while the headers went on reporting a budget.
windowMs: 60_000,
key: (ctx) => ctx.
server: Bun.Server<unknown>
The server handling this request — the real Bun.Server, whole and
unguarded, like req an escape hatch to the platform.
The way to reach connection- and server-level facts a Request does
not carry: ctx.server.requestIP(ctx.req) for rate limiting by
address, ctx.server.timeout(ctx.req, seconds) for a per-request idle
timeout.
An address is in the form the socket reports it: a server listening on
both stacks — Bun's default — reports an IPv4 client as
::ffff:203.0.113.7, which a comparison with 203.0.113.7 misses.
Deliberately not a facade: every hook is code the
application author wrote or vetted, and hiding stop/reload from
in-process code protects nothing. They are still process-level
operations with no business inside a request — calling
ctx.server.stop() from a hook takes the whole listener down.
upgrade is for the endpoints ws() declares, which the application
upgrades itself. A socket upgraded by hand reaches the application's
websocket handler with no endpoint on it: the connection drops, and
each of its events is reported as a failure.
server.requestIP(ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}
The raw incoming request, always available as an escape hatch.
On a request that matched a route, Bun's router delivers its own
request object carrying cookies — a Bun.CookieMap whose mutations
are applied to the response as Set-Cookie automatically, with Bun's
defaults (Path=/; SameSite=Lax). The field is optional because it
does not exist where Bun's router was not involved: the 404 fallback
and unit-tested handlers. ctx.out.headers.append("set-cookie", ...)
is the fallback that works everywhere.
req)?.
address: string |undefined
The IP address of the client.
address,
});
The expiry is set on every hit, only if the key has none (NX, Redis 7 and
later). Set only on the first hit, a timeout between the two commands would
leave a counter that never expires, and its client refused for good. On older
Redis, send both commands in one MULTI.
A limiter with a store needs a name, which starts every key it counts under:
shop-login:203.0.113.7, or shop-login:POST:/login:203.0.113.7 with
perRoute. So the name is the budget:
Every server of a fleet whose limiter has that name shares one budget, which
is what a shared store is for.
A name must be unique across everything that writes to the store. Two
services on one Redis need different names, or a store that prefixes every
key with the service.
Two limiters with one name on one store but different settings are refused.
The same settings under one name are fine, since every test run and every
server makes its limiters again.
In tests, every request comes from one address.serve() and its
client() connect from the test process, so a limiter keyed by address
counts all requests of a test file in one bucket. Build the application per
test, or pass it a key the test controls. See Testing.
With @tetsujs/openapi, every operation the
limiter guards is documented with a 429, its retryAfter and its
retry-after header.
The package also exports memoryStore and the types RateLimitOptions,
RateLimitHook, RateLimitStore, WindowState, LimitSlot,
OwnCounters and SharedCounters. RateLimitHook is a beforeParse
limiter whose key reads only the request; type any other limiter with
ReturnType of its own rateLimit() call.