Rate limiting
Rate limiting protects routes at the HTTP boundary while keeping the storage
backend behind RateLimitPort. Contracts declare the limit, hooks enforce it,
and providers decide where counters live.
Setup
Use the Upstash provider for distributed rate limiting:
bun add @beignet/provider-rate-limit-upstash @upstash/redis @upstash/ratelimitimport { createNextServer } from "@beignet/next";
import { createAnonymousActor } from "@beignet/core/ports";
import { createRateLimitHooks } from "@beignet/core/server";
import { upstashRateLimitProvider } from "@beignet/provider-rate-limit-upstash";
import { appPorts } from "@/infra/app-ports";
export const server = await createNextServer({
ports: appPorts,
providers: [upstashRateLimitProvider],
hooks: [createRateLimitHooks<AppContext>()],
context: ({ ports }) => ({
actor: createAnonymousActor(),
ports,
}),
});The provider reads UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, and
the optional UPSTASH_PREFIX and UPSTASH_ALGORITHM values from environment
variables. It contributes the standard rateLimit port plus
ctx.ports.upstash with the raw Upstash Redis client as an
escape hatch for Upstash-specific operations.
UPSTASH_ALGORITHM selects the rate limit algorithm: fixed-window (the
default) is cheaper but can allow bursts at window boundaries, while
sliding-window smooths those bursts at slightly more Redis work per hit.
Switching algorithms changes how counters are keyed in Redis, so in-flight
windows reset when the algorithm changes.
For scope: "user" limits, install auth hooks before rate limiting so the
anonymous baseline actor is replaced with the signed-in user actor.
Contract metadata
Declare route-specific limits on the contract:
export const createComment = comments
.post("/")
.meta({
rateLimit: { max: 10, windowSec: 60, scope: "user" },
})
.body(CreateCommentSchema)
.responses({ 201: CommentSchema });The built-in hook reads contract.metadata.rateLimit and calls
ctx.ports.rateLimit.hit(...).
Scopes
| Scope | Runs | Default key |
|---|---|---|
global | onRequest, before parsing and context | global |
ip | onRequest, before parsing and context | ip:<client-ip> |
user | beforeHandle, after context exists | user:<ctx.actor.id> |
Use global for coarse protection, ip for anonymous traffic, and user for
signed-in workflows. For user limits, put the auth hook before the rate limit
hook so ctx.actor is assigned to a user actor before enforcement. If the
request actor is missing, anonymous, service, or system, the default user key
falls back to global.
Custom keys
Use custom key functions when your app needs tenant, plan, route, or API token scoping:
createRateLimitHooks<AppContext>({
key: ({ ctx, req, scope }) => {
if (scope === "user") {
const actorId =
ctx.actor.type === "user" && ctx.actor.id ? ctx.actor.id : "anonymous";
return `tenant:${ctx.tenant?.id ?? "global"}:user:${actorId}`;
}
return `path:${new URL(req.url).pathname}`;
},
earlyKey: ({ req, scope }) => {
const token = req.headers.get("x-api-key");
return token ? `api-key:${token}` : `${scope}:${new URL(req.url).pathname}`;
},
});Use earlyKey only for global and ip scopes because it runs before request
parsing and context creation.
Trusted proxies and client IPs
ip-scoped limits resolve the client IP from the x-forwarded-for header.
Proxies append the address they saw to the end of the header, so the last
entry is the one written by your platform's trusted reverse proxy. Earlier
entries — including the first — are sent by the client and can be forged to
rotate buckets and bypass IP limits.
By default the hook uses the last x-forwarded-for entry. Use the ipSource
option to change the strategy:
// Default: last entry, appended by the platform's trusted proxy.
createRateLimitHooks<AppContext>({ ipSource: "x-forwarded-for-last" });
// First entry. Only safe behind an edge that strips and rewrites the header.
createRateLimitHooks<AppContext>({ ipSource: "x-forwarded-for-first" });
// Platform-specific headers set by a trusted edge.
createRateLimitHooks<AppContext>({
ipSource: (req) => req.headers.get("cf-connecting-ip") ?? undefined,
});Use "x-forwarded-for-first" only when a trusted edge normalizes the header
before it reaches the app. Prefer a custom ipSource function when your
platform sets a dedicated client-IP header such as cf-connecting-ip or
x-real-ip. When no IP can be resolved, the key falls back to ip:unknown,
which shares a single bucket across all unidentified clients.
Failure behavior
When the limit is exceeded, createRateLimitHooks throws an AppError using
Beignet's 429 Too Many Requests catalog error. Because the error comes
from a hook, the response is framework-owned and does not need to appear in
every route's .responses(...).
Denial details sent to clients contain scope, retryAfterSeconds, and
resetAt. The bucket key — which can embed user IDs, client IPs, or API
token fragments — is never serialized into the response body. Each denial
also emits a rateLimit.denied instrumentation event with the key, scope,
limit, and window so operators can see which bucket was exhausted in the
devtools Rate limits tab.
If your app wants custom headers or response bodies, add a Beignet error
mapping hook or implement a small app-owned rate limit hook that still calls
ctx.ports.rateLimit.
Devtools
Rate limit checks appear in the Rate limits view of devtools when the devtools provider is installed before the Upstash rate limit provider.
Direct use
Use the port directly for non-HTTP workflows or app-specific limits:
import { AppError } from "@beignet/core/errors";
const result = await ctx.ports.rateLimit.hit({
key: `password-reset:${email}`,
limit: 3,
windowSec: 900,
});
if (!result.allowed) {
throw new AppError(errors.PasswordResetRateLimited);
}Testing
Tests can use the first-party in-memory adapter:
import { createMemoryRateLimiter } from "@beignet/core/ports";
const rateLimit = createMemoryRateLimiter();It uses fixed windows and returns the same allowed, remaining, resetAt,
and retryAfterSeconds shape as production providers.