sdxc

[ Operations ]

@sdxc/rate-limit

Adapter-based rate limiting with standard response headers

npm add @sdxc/rate-limit remix
pnpm add @sdxc/rate-limit remix
yarn add @sdxc/rate-limit remix
bun add @sdxc/rate-limit remix
Used by
uptime, auth-saas, reader, blog

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

AdapterCountingAccuracyReports remainingreset
CloudflareAdapterPlatform bindingPer location, platform-definedNoFails
KVAdapterFixed window in KVApproximate under concurrencyYesYes
DataTableAdapterSliding window in SQLExact except for in-flight writesYesYes
MemoryAdapterFixed window in processExact, per isolateYesYes

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 counts

  • key(context): what one budget belongs to; required

  • prefix?: namespace prepended as prefix:key; defaults to a name from registration order, so pass one whenever keys are persisted or inspected

  • cost?: units one request spends, a number or a function of the context; defaults to 1

  • skip?(context): lets a request through uncounted

  • onLimit?(context, decision): builds the denial response; the rate limit headers are added to it

  • failurePolicy?: "open" (default) lets a request through when the backend cannot answer, "closed" refuses it with a 429 carrying 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 (null when the backend cannot report it), reset (a Date) and retryAfter (seconds).

  • FailurePolicy: "open" | "closed", from ./middleware.

  • RateLimiterBinding and RateLimitKVNamespace: the slice of a rate limiter binding and a KV namespace the adapters use, so a real binding or a test double fits.

  • RateLimitAddress: what addressKey accepts. RateLimitHitRow: one loaded rate_limit_hits row. RateLimitBackend: the backend names.

  • MemoryAdapterOptions, CloudflareAdapterOptions, KVAdapterOptions, DataTableAdapterOptions, RateLimitErrorOptions and, 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");
});

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.