Skip to content

Operations

Ameya Borkar edited this page May 26, 2026 · 7 revisions

Operations

Headers, IP keys, PII safety, observability, and failure modes.

Standards-compliant headers

buildRateLimitHeaders(decision, opts) produces a plain Record<string, string> (the adapters call it for you), in three families via emit:

  • draft (default) — the IETF RateLimit-Limit/-Remaining/-Reset triple.
  • structured — RFC 9651 RateLimit + RateLimit-Policy.
  • legacy — the X-RateLimit-* triple.

On a denial a Retry-After (delta-seconds, min 1) is always added, and all time math derives from the injected now.

Trusted proxy & IPv6 aggregation

Trusting X-Forwarded-For blindly is the classic bypass. clientIp refuses to: the default is trustProxy: false (use the socket peer), trust is opt-in as a hop count or CIDR allowlist, and it aggregates IPv6 to a configurable prefix (/64 default) so one customer can't rotate through billions of addresses.

import { clientIp } from "throttlekit";

const key = clientIp(
  { remoteAddr: req.socket.remoteAddress ?? "", xForwardedFor: req.headers["x-forwarded-for"] },
  { trustProxy: ["10.0.0.0/8"], ipv6Prefix: 64 }, // or trustProxy: 1 for a single hop
);

The Express and fetch adapters accept trustProxy/ipv6Prefix directly and derive this key by default.

PII-safe keys (HMAC)

Hash raw identifiers with a server secret before they reach the store, so a shared Redis never holds the raw value:

import { hmacKeyer } from "throttlekit";
const keyer = hmacKeyer(process.env.RL_SECRET ?? "");
await limiter.check(keyer(rawUserId));

Observability

Every Decision is a plain, loggable object. For metrics, the optional OpenTelemetry layer (throttlekit/otel) wraps a limiter or guard with your own Meter:

import { instrumentLimiter, instrumentGuard } from "throttlekit/otel";
import { metrics } from "@opentelemetry/api";

const meter = metrics.getMeter("my-service");
const observed = instrumentLimiter(limiter, meter); // throttlekit.checks / .remaining / .store.latency
instrumentGuard(guard, meter);                       // concurrency.limit / .inflight / .rtt_noload

For zero-config insight without a metrics backend, wrap a limiter with withAnalytics — it tracks allow/deny counts and the top-K heavy hitters (keys driving the most traffic and denials) in bounded memory via Space-Saving (Metwally et al. 2005), so your worst offenders surface even under a flood of unique keys:

import { withAnalytics, rateLimit, gcra } from "throttlekit";

const limiter = withAnalytics(rateLimit({ strategy: gcra({ limit: 100, periodMs: 60_000 }) }));
await limiter.check(clientIp); // use exactly like any limiter
const a = limiter.analytics(); // { allowed, denied, total, denyRate, topRequested: [...], topDenied: [...] }

Failure modes

The in-process MemoryStore never fails. A distributed store can: if Redis is unreachable, check() rejects (StoreUnavailableError). You decide what that means — every adapter takes a fail policy and fires onError before applying it:

fail On a store outage Use when
"open" (default) Allow the request Availability > the cap — most public APIs
"closed" Reject with 503 The cap is a hard guarantee — billing, abuse-critical paths
expressRateLimit({
  strategy: gcra({ limit: 100, periodMs: 60_000 }),
  store: redisStore,
  fail: "closed",
  onError: (_req, _res, err) => log.warn({ err }, "rate limiter store down"),
});

Two extra hedges: twoTier leased keeps serving from the local lease while L2 is briefly unreachable, and the Redis path is a single atomic round trip (no read-then-write window to interrupt). Both fail modes are tested on every adapter.

Clone this wiki locally