Rate limiting
Rate limiting in the Security guide.
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.
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.
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-widemiddleware) takes one token for the request and, once the batch is read,costmore 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.
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.