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 } 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>()],
  createContext: ({ ports }) => ({
    actor: createAnonymousActor(),
    ports,
  }),
});

The provider reads UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, and optional UPSTASH_PREFIX from environment variables.

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

ScopeRunsDefault key
globalonRequest, before parsing and contextglobal
iponRequest, before parsing and contextip:<client-ip>
userbeforeHandle, after context existsuser:<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 createContext.

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(...).

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

When the devtools provider is installed before the Upstash rate limit provider, rate limit checks appear in the Rate limits tab. The provider records the key, limit, window, configured prefix, allowed/blocked result, remaining count, reset time, retry-after value, and duration. Provider failures are recorded without changing the original thrown error.

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.