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, createNextServerLoader } from "@beignet/next";
import { createAnonymousActor } from "@beignet/core/ports";
import { createRateLimitHooks } from "@beignet/core/server";
import { createUpstashRateLimitProvider } from "@beignet/provider-rate-limit-upstash";
import { initialPorts } from "@/infra/port-wiring";
export const getServer = createNextServerLoader(() =>
createNextServer({
ports: initialPorts,
providers: [createUpstashRateLimitProvider()],
hooks: [createRateLimitHooks<AppContext>()],
context: ({ ports }) => ({
actor: createAnonymousActor(),
ports,
}),
}),
);createUpstashRateLimitProvider(options) configures the Upstash connection,
prefix, and algorithm in code; options override env-derived values.
The provider reads UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, and
the optional UPSTASH_PREFIX, UPSTASH_ALGORITHM, UPSTASH_TIMEOUT_MS, and
UPSTASH_TIMEOUT_POLICY 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.
Rate-limit decisions are bounded to 5000 milliseconds and fail closed by
default. Set UPSTASH_TIMEOUT_MS to another positive integer when the
deployment needs a tighter budget; the maximum supported value is
2147483647 milliseconds. Set
UPSTASH_TIMEOUT_POLICY=fail-open only when availability is more important
than enforcement during an Upstash outage.
When app infrastructure already owns an Upstash Redis client, use the direct adapter instead of the env-backed lifecycle provider:
import { createUpstashRateLimit } from "@beignet/provider-rate-limit-upstash";
const rateLimit = createUpstashRateLimit({
client: appUpstashRedis,
prefix: "myapp:ratelimit",
algorithm: "sliding-window",
timeoutMs: 2500,
timeoutPolicy: "fail-closed",
});The caller owns the client lifecycle. The adapter retains the provider's dynamic per-contract limits and accepts an optional instrumentation target.
For scope: "user" limits, attach auth route hooks to the protected route or
route group so the signed-in user actor is present before the server
beforeHandle phase enforces the limit.
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 | contract:<contract-name>:global |
ip | onRequest, before parsing and context | contract:<contract-name>:ip:<client-ip> |
user | beforeHandle, after route hooks resolve identity | contract:<contract-name>:user:<ctx.actor.id> |
Use global for one counter shared by every caller of that contract, ip for
anonymous traffic, and user for signed-in workflows. global does not merge
counters across contracts; use a custom key when an app-wide aggregate is
intentional. For user limits, attach an auth route hook so
ctx.actor is assigned to a user actor before the server beforeHandle phase.
If the request actor is missing, anonymous, service, or system, the hook fails
with AuthUnauthorizedError instead of sharing a global bucket. Default keys
include the contract name, so one route cannot consume another route's limit.
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 `path:${new URL(req.url).pathname}: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. A custom key or earlyKey replaces the complete
default key. Include route or contract identity when different routes should
have independent counters.
Trusted proxies and client IPs
ip-scoped limits require an explicit server-level trustedProxy.clientIp,
hook-local trustedProxy.clientIp, ipSource, or custom earlyKey. When a
registered contract declares scope: "ip" and no client-IP strategy exists,
the hook fails
createServer(...) startup with a configuration error that names the contract
— the alternative would be silently collapsing all clients into one shared
bucket. Routes added later through server.route(...) are covered by the same
error at enforcement time.
Proxies append the address they saw to the end of x-forwarded-for, so the
last entry is the one written by your platform's trusted reverse proxy when the
app always sits behind that proxy. Earlier entries — including the first — are
sent by the client and can be forged to rotate buckets and bypass IP limits.
Configure trustedProxy only when the app is always behind an edge that strips
or normalizes those headers before they reach application code.
createNextServer({
// Last entry, appended by the platform's trusted proxy.
trustedProxy: { clientIp: "x-forwarded-for-last" },
hooks: [createRateLimitHooks<AppContext>()],
// ...
});
createNextServer({
// First entry. Safe only behind an edge that strips and rewrites the header.
trustedProxy: { clientIp: "x-forwarded-for-first" },
hooks: [createRateLimitHooks<AppContext>()],
// ...
});
createNextServer({
// Platform-specific header set by a trusted edge.
trustedProxy: { clientIp: "cf-connecting-ip" },
hooks: [createRateLimitHooks<AppContext>()],
// ...
});
createNextServer({
// Custom platform header.
trustedProxy: { clientIp: { header: "x-client-ip" } },
hooks: [createRateLimitHooks<AppContext>()],
// ...
});
// Explicit opt-out: trust no headers; each contract's ip-scoped traffic
// shares one unknown-client bucket.
createRateLimitHooks<AppContext>({ ipSource: "none" });Use "x-forwarded-for-first" only when a trusted edge normalizes the header
before it reaches the app. When a configured client-IP source cannot resolve an
IP for a request, the key falls back to the current contract's
contract:<contract-name>:ip:unknown bucket. With ipSource: "none", every
request to that contract lands in its unknown-client bucket. This turns the
contract's ip-scoped limit into one shared limit for unidentified traffic —
an explicit choice, never a silent default.
The server resolves this policy once per request. The context factory and every
server-hook phase receive the same requestInfo, and
createCsrfHooks(...) uses its external origin automatically. A hook-local
trustedProxy option overrides the server policy only for that hook.
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, and the 429 response carries a standard Retry-After header
whenever the limiter reports a reset time, so generic HTTP clients back off
without parsing the Beignet error body. 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 other 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.
Backend errors are not rate-limit denials. The Upstash provider records the failed check and rethrows the original error, so the request fails instead of continuing without enforcement.
When Upstash does not respond before UPSTASH_TIMEOUT_MS, the default
fail-closed policy records rateLimit.hit.failed and throws a timeout error.
The explicit fail-open policy allows the request with unknown
remaining/reset values and emits rateLimit.hit.degraded. Alert on degraded
events: they mean the route continued without a confirmed rate-limit decision.
Readiness
The Upstash escape hatch exposes a read-only Redis PING:
const health = await ctx.ports.upstash.checkHealth();Use it in an app-owned readiness route. beignet preflight --connect
discovers the same checkHealth() method and fails when Upstash is unavailable.
The check uses the provider's configured timeout. The REST-based provider starts
no worker or persistent connection, so it is safe in serverless and long-lived
HTTP runtimes.
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. Its counters are
per-process — see
Process boundaries of memory providers.