Files
skills/docs/api-stack/api/api-rate-limit/src/middleware/middleware.md
2026-05-29 08:20:56 +01:00

4.2 KiB

middleware

(no submodules)

Purpose

The package's centerpiece. createRateLimitMiddleware builds a per-request gate around the token-bucket store; the gate emits 429 responses with Retry-After, increments the api.rate_limit.exceeded_total counter on every denied request, and projects itself onto the host's RequestRateLimiter shape so the synchronous consume(key, weight) call site in the host's request pipeline gets the same backing store + instruments without re-deriving the route policy.

Features

  • Route-declared budget { rpm, burst?, key? } with Zod validation at the ingestion boundary. burst defaults to max(1, ceil(rpm/60)) when omitted so a 1-rpm route still tolerates a single-request burst.
  • Host-level defaults (hostDefaults.perTenant, hostDefaults.perIp) applied when a route declares no rateLimit. Route-level declarations always win.
  • 429 response carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining: 0. Allowed responses carry X-RateLimit-* hints without Retry-After.
  • OTel counter api.rate_limit.exceeded_total increments once per denied request, labelled { plugin, route, key_kind }.
  • asRequestRateLimiter({ plugin, route, option?, keyKind? }) — bridges into the host's consume(key, weight) → boolean contract so the host pipeline keeps the same backing store / instruments.
  • When no budget applies (no route option, no host default), the gate is a no-op so plugins that never opt in pay zero overhead.

Usage: Basic

import { createRateLimitMiddleware } from "@suaveplan/api-rate-limit";

const mw = createRateLimitMiddleware();
const decision = mw.check({
    plugin: "billing",
    route: "POST /invoices",
    option: { rpm: 60, burst: 5 },
    ctx: {
        tenantId: "t1",
        principal: { id: "alice" },
        ip: "10.0.0.1",
        request: new Request("https://h/invoices", { method: "POST" }),
    },
});
if (!decision.allowed) {
    // Return new Response(null, { status: 429, headers: decision.headers })
}

Usage: Advanced

Inject a host-level default for IP-keyed buckets and a custom selector on a specific route. Drive the bucket forward against a FakeClock:

import { createRateLimitMiddleware } from "@suaveplan/api-rate-limit";
import { createMemoryRateLimitStore } from "@suaveplan/api-rate-limit/store";

let now = 0;
const clock = () => now;
const store = createMemoryRateLimitStore({ clock });
const mw = createRateLimitMiddleware({
    store,
    clock,
    hostDefaults: { perIp: { rpm: 30, burst: 3 } },
});

const limiter = mw.asRequestRateLimiter({
    plugin: "billing",
    route: "POST /invoices",
    option: { rpm: 60, burst: 5 },
    keyKind: "principal",
});
limiter.consume("principal:alice", 1); // → true, drains 1 token

API Reference

  • interface RateLimitRouteOption { rpm: number; burst?: number; key?: RateLimitKeySelector }
  • const RateLimitRouteOptionSchema — Zod schema, runs at the ingestion boundary.
  • interface HostRateLimitDefaults { perTenant?: RateLimitBudget; perIp?: RateLimitBudget }
  • interface CreateRateLimitMiddlewareOptions { store?; instruments?; clock?; hostDefaults? }
  • interface RateLimitDecision { allowed; status; retryAfterSeconds; headers; key; keyKind }
  • interface RequestRateLimiter { consume(key, weight): boolean }
  • interface RateLimitMiddleware { check; asRequestRateLimiter; store; instruments }
  • function createRateLimitMiddleware(options?: CreateRateLimitMiddlewareOptions): RateLimitMiddleware

Implementation Notes

The Zod schema runs once per route-option ingestion (cheap, not per request) — the check hot path consults the parsed shape directly. The decision returns a frozen header bag so downstream layers cannot mutate it unintentionally. The key_kind attribute is bounded to the four-value union from ResolvedKey.kind so OTel cardinality stays bounded even for a custom selector. The asRequestRateLimiter projection captures route policy at construction time because the host's synchronous consume(key, weight) shape has no slot for it; this keeps the host's per-request hot path single-load instead of re-resolving the route policy on every call.