Skip to content

@tetsujs/rate-limit

@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
bun add @tetsujs/rate-limit

It depends on @tetsujs/openapi, which it uses to document the refusal.

import { rateLimit } from "@tetsujs/rate-limit";
const limit = 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
,
});
createApp({ hooks: { beforeParse: [limit] }, routes });

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:

const feedback = 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: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => {
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.

handler
: () => ({
received: boolean
received
: true }),
});

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:

const burst = 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
});
const hourly = 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 });
const login = 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: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => {
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";
const each = 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.

req
)?.
address: string | undefined

The IP address of the client.

address
,
});
createApp({ hooks: { beforeParse: [each] }, routes });

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:

import { rateLimit } from "@tetsujs/rate-limit";
// createApp({ cookies: { secret, sign: ["session"] } }) signs the session
const auth = hook.beforeParse((ctx) => {
const
const userId: string | undefined
userId
= signedCookie(ctx, "session");
if (!
const userId: string | undefined
userId
) throw new HttpError(401);
return {
userId: string
userId
};
});
const perUser = rateLimit({
limit: number

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
,
});
const createOrder = 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: {
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;
}) => {
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
const internal: Set<string>
internal
= new Set(["10.0.0.5", "10.0.0.6"]);
const limit = 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
const address: 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
const address: string | undefined
address
&&
const internal: Set<string>
internal
.has(
const address: string
address
) ? undefined :
const address: 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";
import type { Requires } from "@tetsujs/core";
const perAccount = 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
,
});
const perAddress = 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
,
});
const login = 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",
schema: { body: Login },
hooks: { beforeParse: [perAddress], beforeHandle: [perAccount] },
handler: (ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
email: string;
password: string;
};
}) => {
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 }),
});

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.

Option Default
limit required 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";
const
const redisStore: RateLimitStore
redisStore
: RateLimitStore = {
hit: (key: string, windowMs: number) => WindowState | Promise<WindowState>

Counts one hit against a key and returns the window it fell into.

A key that has no window, or whose window has ended, starts a new one of windowMs milliseconds.

hit
: async (
key: string
key
,
windowMs: number
windowMs
) => {
const
const count: number
count
= await
const redis: {
incr(key: string): Promise<number>;
pexpire(key: string, ms: number, mode: "NX"): Promise<unknown>;
pttl(key: string): Promise<number>;
}
redis
.incr(
key: string
key
);
await
const redis: {
incr(key: string): Promise<number>;
pexpire(key: string, ms: number, mode: "NX"): Promise<unknown>;
pttl(key: string): Promise<number>;
}
redis
.pexpire(
key: string
key
,
windowMs: number
windowMs
, "NX");
return {
count: number

Hits in the current window, this one included.

count
,
resetAt: number

When the window ends, as epoch milliseconds.

resetAt
: Date.now() + (await
const redis: {
incr(key: string): Promise<number>;
pexpire(key: string, ms: number, mode: "NX"): Promise<unknown>;
pttl(key: string): Promise<number>;
}
redis
.pttl(
key: string
key
)) };
},
};
const limit = rateLimit({
name: string

What tells this limiter's counters from any other's in the store.

name
: "shop-login",
store: RateLimitStore

Where the counters live.

store
:
const redisStore: 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.