[ Operations ]
@sdxc/rate-limit
Adapter-based rate limiting with standard response headers
- Used by
- uptime, auth-saas, reader, blog
- Source
- packages/rate-limit
Adapter-based rate limiting with standard response headers.
An adapter counts attempts against a key and answers a decision; the middleware spends that
budget for each request and writes the decision into the RateLimit and RateLimit-Policy
fields of
draft-ietf-httpapi-ratelimit-headers-07.
The same limit runs on a Cloudflare rate limiter binding, Workers KV, a SQL table or in process,
chosen per endpoint.
Installation
npm add @sdxc/rate-limit
Windows are @sdxc/duration values, adapters
answer a Result from @sdxc/result, and the
./middleware entry point mounts on a remix v3 router.
Usage
Count an attempt
An adapter needs no request, so a job, a queue consumer or an outbound-call budget uses it directly.
import { MemoryAdapter } from "@sdxc/rate-limit";
import { isSuccess } from "@sdxc/result";
let adapter = new MemoryAdapter({ limit: 100, window: "1 minute" });
async function sendAlert(teamId: string) {
let result = await adapter.consume(`alerts:${teamId}`);
if (isSuccess(result) && !result.data.allowed) return; // over budget until result.data.reset
await deliverAlert(teamId);
}
Limit a router
rateLimit counts every request in scope before the handler runs and writes the quota onto the
response. key says what one budget belongs to: an authenticated identity is the strongest
choice — a token id, a client id, a tenant.
import { CloudflareAdapter } from "@sdxc/rate-limit";
import { rateLimit } from "@sdxc/rate-limit/middleware";
import { createRouter } from "remix/router";
let router = createRouter({
middleware: [
rateLimit({
adapter: new CloudflareAdapter(env.API_RATE_LIMITER, { limit: 20, window: "1 minute" }),
prefix: "api",
key: (context) => context.request.headers.get("X-Client-Id") ?? "anonymous",
}),
],
});
A denied request never reaches the handler:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
RateLimit: limit=20, reset=37
RateLimit-Policy: 20;w=60
Retry-After: 37
{"error":"too_many_requests","error_description":"Rate limit exceeded. Please try again later."}
Key anonymous callers by address
An anonymous endpoint keys on the connecting address. addressKey keeps an IPv4 address whole
and widens an IPv6 address to its /64, since one IPv6 client holds a whole /64; a missing
address lands in one shared "unknown" bucket.
import { getClientIP } from "@sdxc/get-client-ip";
import { addressKey, MemoryAdapter } from "@sdxc/rate-limit";
import { rateLimit } from "@sdxc/rate-limit/middleware";
let limiter = rateLimit({
adapter: new MemoryAdapter({ limit: 60, window: "1 minute" }),
prefix: "public",
key: (context) => addressKey(getClientIP(context.request)),
});
Answer a denial yourself
Outside the middleware, tooManyRequests builds the 429 with the same quota fields, and
applyRateLimitHeaders writes them onto any other response.
import { applyRateLimitHeaders, MemoryAdapter, tooManyRequests } from "@sdxc/rate-limit";
import { isFailure } from "@sdxc/result";
let adapter = new MemoryAdapter({ limit: 5, window: "10 seconds" });
async function handle(request: Request, clientId: string): Promise<Response> {
let result = await adapter.consume(`export:${clientId}`);
if (isFailure(result)) return runExport(request);
if (!result.data.allowed) {
return tooManyRequests(result.data, adapter.window, JSON.stringify({ error: "slow_down" }), {
headers: { "Content-Type": "application/json" },
});
}
return applyRateLimitHeaders(await runExport(request), result.data, adapter.window);
}
API
Adapter
The contract every backend implements: limit, window, consume(key, cost?) and
reset(key), both answering a Result with a RateLimitError failure.
consume spends cost units (default 1, raised to a whole number of at least 1) and answers a
RateLimitDecision. A denied attempt spends nothing, so a client hammering a limited key never
pushes its own reset further away. reset clears the key so its next attempt starts a fresh
budget.
Adapters
| Adapter | Counting | Accuracy | Reports remaining | reset |
|---|---|---|---|---|
CloudflareAdapter | Platform binding | Per location, platform-defined | No | Fails |
KVAdapter | Fixed window in KV | Approximate under concurrency | Yes | Yes |
DataTableAdapter | Sliding window in SQL | Exact except for in-flight writes | Yes | Yes |
MemoryAdapter | Fixed window in process | Exact, per isolate | Yes | Yes |
A fixed window frees every client's budget at once; a sliding one frees it gradually.
new MemoryAdapter({ limit, window })
Counters in a Map held by the instance, for tests and local development. clear() drops every
counter.
new CloudflareAdapter(binding, { limit, window })
Counts through a Cloudflare rate limiter binding. limit and window must match the binding's
own limit and period: the adapter derives reset and retryAfter from them, so a mismatch
keeps limiting correct and makes every header wrong. A cost above 1 calls the binding once per
unit.
new KVAdapter(kv, { limit, window, prefix? })
Fixed-window counters in a Workers KV namespace under prefix:key:window-start, expiring by TTL
(at least KV's 60-second minimum). prefix defaults to "rate-limit".
new DataTableAdapter(db, { limit, window })
Sliding-window counters as rows in rate_limit_hits through remix/data-table, one row per
counted attempt. Aged-out rows are deleted when their bucket is next consumed.
rateLimitHits and RATE_LIMIT_HITS_SCHEMA_SQL
The table() definition the adapter queries (id, bucket, cost, created_at in epoch
milliseconds), and the create table plus (bucket, created_at) index to paste into your own
migration.
rateLimit(options): Middleware
From @sdxc/rate-limit/middleware. Counts the request before next(), refuses it when the
budget is gone, and writes the decision onto whatever response comes back.
adapter: the backend that countskey(context): what one budget belongs to; requiredprefix?: namespace prepended asprefix:key; defaults to a name from registration order, so pass one whenever keys are persisted or inspectedcost?: units one request spends, a number or a function of the context; defaults to1skip?(context): lets a request through uncountedonLimit?(context, decision): builds the denial response; the rate limit headers are added to itfailurePolicy?:"open"(default) lets a request through when the backend cannot answer,"closed"refuses it with a429carrying no rate limit headers
It logs rate_limit.exceeded on a denial and rate_limit.unavailable on a backend failure
through @sdxc/logger; the key stays out of the
log.
rateLimitHeaders(decision, window): [string, string][]
The header pairs for a decision, in write order. A field the backend cannot report is left out,
and Retry-After appears only on a denial.
rateLimitHeaders(decision, "10 seconds");
// [["RateLimit", "limit=10, remaining=0, reset=7"], ["RateLimit-Policy", "10;w=10"], ["Retry-After", "7"]]
applyRateLimitHeaders(response, decision, window): Response
Writes those headers onto a response, answering an equivalent copy when its headers are immutable.
tooManyRequests(decision, window, body?, init?): Response
A 429 Too Many Requests with the decision's headers. The status always wins over init, and
the body keeps whatever Content-Type the caller set.
addressKey(address): string
The budget key for a parsed address: its /32 (IPv4) or /64 (IPv6) network in canonical text,
or UNKNOWN_ADDRESS_KEY ("unknown") for null or undefined. Any value with
network({ v4, v6 }), such as the IP from
@sdxc/get-client-ip or
@sdxc/ip, fits.
RateLimitError
The failure an adapter answers when its backend cannot, with backend ("memory",
"cloudflare", "kv" or "data-table"), the prefixed key and the original error as cause.
It carries no decision: whether to let the request through is the caller's call.
Types
RateLimitDecision:allowed,limit,remaining(nullwhen the backend cannot report it),reset(aDate) andretryAfter(seconds).FailurePolicy:"open" | "closed", from./middleware.RateLimiterBindingandRateLimitKVNamespace: the slice of a rate limiter binding and a KV namespace the adapters use, so a real binding or a test double fits.RateLimitAddress: whataddressKeyaccepts.RateLimitHitRow: one loadedrate_limit_hitsrow.RateLimitBackend: the backend names.MemoryAdapterOptions,CloudflareAdapterOptions,KVAdapterOptions,DataTableAdapterOptions,RateLimitErrorOptionsand, from./middleware,RateLimitMiddlewareOptions.
Pattern: One Limiter Per Protected Surface
Each registration has its own prefix, so two limiters over one backend keep separate counters even when they derive the same key.
import { getClientIP } from "@sdxc/get-client-ip";
import { addressKey, CloudflareAdapter } from "@sdxc/rate-limit";
import { rateLimit } from "@sdxc/rate-limit/middleware";
import { createRouter } from "remix/router";
let tokenLimiter = rateLimit({
adapter: new CloudflareAdapter(env.TOKEN_RATE_LIMITER, { limit: 20, window: "1 minute" }),
prefix: "token",
key: (context) => context.request.headers.get("X-Client-Id") ?? "anonymous",
});
let loginLimiter = rateLimit({
adapter: new CloudflareAdapter(env.LOGIN_RATE_LIMITER, { limit: 10, window: "10 seconds" }),
prefix: "login",
key: (context) => addressKey(getClientIP(context.request)),
});
let router = createRouter();
router.map(routes.token, { middleware: [tokenLimiter], handler: issueToken });
router.map(routes.login, { middleware: [loginLimiter], handler: logIn });
Pattern: Weighted Costs, Exemptions And An HTML Denial
cost charges expensive requests more, skip exempts trusted ones, and onLimit answers a page
instead of JSON while the middleware still adds the headers.
import { KVAdapter } from "@sdxc/rate-limit";
import { rateLimit } from "@sdxc/rate-limit/middleware";
let searchLimiter = rateLimit({
adapter: new KVAdapter(env.RATE_LIMITS, { limit: 100, window: "1 hour", prefix: "search" }),
prefix: "search",
key: (context) => context.request.headers.get("X-Account-Id") ?? "anonymous",
cost: (context) => (new URL(context.request.url).searchParams.has("export") ? 10 : 1),
skip: (context) => context.request.headers.get("X-Health-Check") === "1",
onLimit: (_context, decision) =>
new Response(`<h1>Slow down</h1><p>Try again in ${decision.retryAfter} seconds.</p>`, {
status: 429,
headers: { "Content-Type": "text/html" },
}),
});
Pattern: Failing Closed
The default lets traffic through when the backend is down, so an outage never locks every client out. A surface where an uncounted request is worse than a refused one flips it.
import { getClientIP } from "@sdxc/get-client-ip";
import { addressKey, KVAdapter } from "@sdxc/rate-limit";
import { rateLimit } from "@sdxc/rate-limit/middleware";
let passwordResetLimiter = rateLimit({
adapter: new KVAdapter(env.RATE_LIMITS, { limit: 5, window: "15 minutes" }),
prefix: "password-reset",
key: (context) => addressKey(getClientIP(context.request)),
failurePolicy: "closed",
});
The rate_limit.unavailable log entry names the backend and the policy applied, so the log
says whether traffic flowed.
Pattern: A Sliding Window In SQL
DataTableAdapter runs on any database remix/data-table speaks, and its rows can be queried
to show who is being limited. Add the schema to a migration, and sweep buckets that went quiet,
since rows are pruned only when their bucket is consumed again.
import { DataTableAdapter, RATE_LIMIT_HITS_SCHEMA_SQL, rateLimitHits } from "@sdxc/rate-limit";
import { eq, lt } from "remix/data-table";
// migrations/0007_rate_limit_hits.sql holds RATE_LIMIT_HITS_SCHEMA_SQL verbatim
let adapter = new DataTableAdapter(db, { limit: 30, window: "1 minute" });
let recent = await db.findMany(rateLimitHits, { where: eq("bucket", "login:203.0.113.42/32") });
await db.deleteMany(rateLimitHits, { where: lt("created_at", Date.now() - 60_000) });
Pattern: Lifting A Limit After Success
reset clears a key, so a successful login can forgive the failed attempts before it.
import { KVAdapter } from "@sdxc/rate-limit";
import { isSuccess } from "@sdxc/result";
let failedLogins = new KVAdapter(env.RATE_LIMITS, { limit: 5, window: "15 minutes" });
async function logIn(email: string, password: string): Promise<Response> {
let key = `login:${email.toLowerCase()}`;
let attempt = await failedLogins.consume(key);
if (isSuccess(attempt) && !attempt.data.allowed) return new Response(null, { status: 429 });
if (!(await verifyPassword(email, password))) return new Response(null, { status: 401 });
await failedLogins.reset(key);
return startSession(email);
}
Pattern: Testing A Limited Route
MemoryAdapter counts exactly within one process, and clear() keeps one test's traffic
out of the next.
import { MemoryAdapter } from "@sdxc/rate-limit";
import { rateLimit } from "@sdxc/rate-limit/middleware";
import { createRouter } from "remix/router";
import { afterEach, expect, test } from "vitest";
let adapter = new MemoryAdapter({ limit: 2, window: "1 minute" });
let router = createRouter({
middleware: [rateLimit({ adapter, prefix: "test", key: () => "client" })],
});
router.get("/", () => new Response("ok"));
afterEach(() => adapter.clear());
test("the third request is refused", async () => {
await router.fetch(new Request("https://example.com/"));
await router.fetch(new Request("https://example.com/"));
let response = await router.fetch(new Request("https://example.com/"));
expect(response.status).toBe(429);
expect(response.headers.get("RateLimit-Policy")).toBe("2;w=60");
});