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/ratelimit
import { 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 { appPorts } from "@/infra/app-ports";

export const getServer = createNextServerLoader(() =>
  createNextServer({
    ports: appPorts,
    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 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, 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

ScopeRunsDefault key
globalonRequest, before parsing and contextglobal
iponRequest, before parsing and contextip:<client-ip>
userbeforeHandle, after route hooks resolve identityuser:<ctx.actor.id>

Use global for coarse protection, ip for anonymous traffic, and user for signed-in workflows. 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 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 require an explicit trustedProxy.clientIp, ipSource, or custom earlyKey. When a registered contract declares scope: "ip" and createRateLimitHooks(...) has no client-IP strategy, 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.

// Last entry, appended by the platform's trusted proxy.
createRateLimitHooks<AppContext>({
  trustedProxy: { clientIp: "x-forwarded-for-last" },
});

// First entry. Only safe behind an edge that strips and rewrites the header.
createRateLimitHooks<AppContext>({
  trustedProxy: { clientIp: "x-forwarded-for-first" },
});

// Platform-specific headers set by a trusted edge.
createRateLimitHooks<AppContext>({
  trustedProxy: { clientIp: "cf-connecting-ip" },
});

// Custom platform header.
createRateLimitHooks<AppContext>({
  trustedProxy: { clientIp: { header: "x-client-ip" } },
});

// Explicit opt-out: trust no headers; all ip-scoped traffic shares one
// ip:unknown 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 ip:unknown; with ipSource: "none" every request lands in that shared bucket, which turns an ip-scoped limit into a global one for unidentified traffic — an explicit choice, never a silent default.

The same trusted proxy policy is used by createCsrfHooks(...) when configured there, so browser origin checks can compare against the external x-forwarded-host and x-forwarded-proto written by your edge.

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.

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.