[]previous and next page

Rate limiting

Rate limiting in the Security guide.

For agentsUpdated

createRateLimiter is a token bucket per key. A bucket holds capacity tokens, which is the burst a client may make at once; it refills at refillPerSecond; a request takes cost tokens (default 1). check(key, cost) returns a RateLimitDecision (allowed, remaining, retryAfterMs, resetMs). limiter.middleware() applies it to a request: the default key is the client address, with IPv6 addresses cut to their /64 (addressKey), because one client usually controls a whole /64; requests with no address share one bucket. Pass key to limit by user, API key or route, and cost to charge more for an expensive route. A request that is allowed gets ratelimit-limit, ratelimit-remaining and ratelimit-reset (seconds until the bucket is full). A request that is not gets 429 with the same headers and retry-after in whole seconds (at least 1). The limiter runs as middleware, so a request refused for CSRF or host never takes a token.

Set it for every request with middleware: [limiter.middleware()], or for one route with the API module's middleware export, which wraps every method of that route after the app-wide middleware. Use a different limiter (and the name option when they share a store) for sensitive routes such as sign-in.

src/routes/api/search.tsts
import { createRateLimiter, json } from "@shadow-js/dusk/server";

const limiter = createRateLimiter({ capacity: 2, refillPerSecond: 1, name: "search" });

/** At most 2 requests at once and 1 per second on average, per client address. */
export const middleware = [limiter.middleware()];

export const GET = () => json({ results: [] });

The memory store keeps at most 10 000 buckets (maxKeys), evicting the least recently used, so a flood of distinct keys cannot grow memory; it is per process. For several processes implement RateLimitStore: one method, take(key, spec, cost, now), which must read and update the bucket atomically (a Redis Lua script, a SQL SELECT ... FOR UPDATE). This sketch holds the bucket arithmetic and leaves the atomic read-modify-write to the client you provide; it is type-checked, not run.

src/shared-limit-store.tsts
import type { RateLimitDecision, RateLimitStore } from "@shadow-js/dusk/server";

export interface Bucket {
  readonly tokens: number;
  readonly updatedAt: number;
}

/** Runs `change` on the stored bucket of `key` as one atomic step and stores what it returns. */
export interface AtomicBuckets {
  update<T>(
    key: string,
    change: (current: Bucket | undefined) => { next: Bucket; result: T },
  ): Promise<T>;
}

export function createSharedRateLimitStore(buckets: AtomicBuckets): RateLimitStore {
  return {
    take(key, spec, cost, now) {
      return buckets.update<RateLimitDecision>(key, (current) => {
        const elapsed = current === undefined ? 0 : Math.max(0, now - current.updatedAt) / 1000;
        const held = current === undefined ? spec.capacity : current.tokens;
        const available = Math.min(spec.capacity, held + elapsed * spec.refillPerSecond);
        const allowed = available >= cost;
        const tokens = allowed ? available - cost : available;
        return {
          next: { tokens, updatedAt: now },
          result: {
            allowed,
            remaining: Math.floor(tokens),
            retryAfterMs: allowed ? 0 : Math.ceil(((cost - available) / spec.refillPerSecond) * 1000),
            resetMs: Math.ceil(((spec.capacity - tokens) / spec.refillPerSecond) * 1000),
          },
        };
      });
    },
  };
}

Batches and server functions#

The browser sends the server function calls of one tick as one request, POST /_dusk/fn/_batch, with up to rpc.maxBatchSize calls (25 by default). A limiter that counts requests would give a client 25 calls for the price of one, so Dusk charges each call of a batch as a call of its own:

  • A rate limit middleware (limiter.middleware() in the app-wide middleware) takes one token for the request and, once the batch is read, cost more for each further call. A batch of 25 calls takes 25 tokens. If the bucket cannot pay, the whole batch is refused with the same 429 a single request gets (retry-after, ratelimit-*), before any call runs. A batch of one call, and every single call, costs what it always did.
  • serverFn({ rateLimit }) limits one function. Every call that reaches it over HTTP takes a token, alone or in a batch, from a bucket that belongs to that function and to the caller, so exhausting one function leaves the others alone and one client never uses another's. A call without a token is refused with the 429 of the middleware (retry-after, ratelimit-*); inside a batch only that call is refused (its entry carries the 429 error) and the rest run.
src/server/comments.tsts
import { createRateLimiter, serverFn } from "@shadow-js/dusk/server";

// 5 comments at once, then one every 10 seconds, per client address and per function.
const commentLimiter = createRateLimiter({ capacity: 5, refillPerSecond: 0.1, name: "comment" });

export const addComment = serverFn({ rateLimit: commentLimiter }, async (input, event) => {
  // ...
});

// Limit by signed-in user instead of address, and charge more for an expensive function.
export const exportReport = serverFn(
  {
    rateLimit: {
      limiter: commentLimiter,
      key: (event) => userIdOf(event) ?? "anonymous", // your own session lookup
      cost: 3,
    },
  },
  async () => {
    // ...
  },
);

Calls made from server code (addComment(input) inside a loader, addComment.invoke(...)) are not network calls and are not charged. The limit is checked after the call header and method checks, before the body is read and before the function's guards, validation and middleware run, so a refused call costs nothing more and a call that fails validation still used its token. Like every limiter, this one is per process with the memory store: give it a shared store when several processes serve the app. A function that must stay within a limit the middleware cannot express can still call limiter.check(key, cost) in its own middleware; that runs per call too.

If you rely on a smaller blast radius instead of a limiter, lower rpc.maxBatchSize.