Gateway · Rate limiting
Rate-limit contract and the in-process reference adapter — RateLimiter interface, fixed-window InMemoryLimiter, and the roadmap for distributed adapters.
The gateway's rate-limit substrate is the seam between a host's shared quota store (Redis, Upstash, DynamoDB, Postgres) and the per-tenant / per-user / per-route throttling decision. Today the package ships a contract + one in-process reference adapter; production adapters follow on additive subpaths.
RateLimiter contract
interface RateLimitCheckOptions {
readonly windowMs?: number
readonly maxRequests?: number
}
interface RateLimitDecision {
readonly allowed: boolean
readonly remaining: number
readonly resetAt: number // epoch ms; window reset
readonly retryAfterMs?: number // populated only when !allowed
}
interface RateLimiter {
check(key: string, opts?: RateLimitCheckOptions): Promise<RateLimitDecision>
}The contract is intentionally minimal: given a key plus optional per-call overrides, return a decision indicating whether the call is allowed, the remaining quota in the current window, and the reset time.
The decision shape mirrors what production adapters (Upstash, AWS
Throttle Tokens) emit. Callers render HTTP 429 responses with
accurate Retry-After + X-RateLimit-* headers without per-adapter
glue.
Implementation requirements
check()MUST be atomic — the count increment + comparison against the ceiling MUST happen as a single observable operation. The in-process reference adapter achieves this via the single-threaded JavaScript event loop; future Redis / Upstash adapters use Lua scripts orINCR+EXPIREpipelines.check()MUST NOT throw under normal load — return a conservativeallowed: falseif the underlying substrate misbehaves and let the caller decide whether to fail open or closed.- Distinct
keyvalues MUST be independent — a denied tenant does not affect a peer tenant's quota.
In-process reference adapter
import { createInMemoryRateLimiter } from "@pleach/gateway/rateLimit"
const limiter = createInMemoryRateLimiter({
defaultWindowMs: 60_000,
defaultMaxRequests: 100,
})
const decision = await limiter.check(`tenant:${tenantId}`)
if (!decision.allowed) {
return new Response("Too Many Requests", {
status: 429,
headers: {
"Retry-After": String(Math.ceil((decision.retryAfterMs ?? 0) / 1000)),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": String(Math.ceil(decision.resetAt / 1000)),
},
})
}Configuration
interface InMemoryRateLimiterConfig {
readonly defaultWindowMs?: number // default 60_000
readonly defaultMaxRequests?: number // default 60
}Per-call overrides are passed to check():
// Tighter limit for an expensive route, same limiter instance.
await limiter.check(`tenant:${tenantId}:expensive-llm`, {
windowMs: 60_000,
maxRequests: 5,
})What it is
Fixed-window counter stored in an in-process Map. Simpler than a
sliding-window log and sufficient for local dev, single-node
deployments, and unit tests.
What it is NOT
Not distributed-safe. Counters live in the calling process's heap. Multiple Node.js instances behind a load balancer will each track an independent count, so a request might pass the limiter on instance A while instance B is at the ceiling. For multi-node production deployments use a shared substrate — see the roadmap below.
Memory bound is lazy. Idle keys are reaped only when a check()
call observes that the stored window expired. A long tail of one-shot
keys can accumulate; hosts that route via short-lived per-user keys
should prefer a production adapter with explicit eviction policy.
Roadmap
Production adapters ship as additive subpaths so the base package
stays dependency-free. None of these are in the current 0.x cut —
they are planned for v1.x:
@pleach/gateway/rateLimit/redis—ioredis/redisv4+. AtomicINCR+EXPIREpipeline; sliding-window via Lua script.@pleach/gateway/rateLimit/upstash—@upstash/ratelimit. HTTP REST API, fits Workers / Edge runtime.@pleach/gateway/rateLimit/dynamodb— AWS SDK v3. Conditional writes + TTL-based window reset.
Per-tenant-call-class policy (different windows for utility vs
reasoning vs synthesize calls inside one tenant) is also a v1.x
item. Today, hosts that need this layer pass distinct keys at the
call site:
// Workaround pre-v1.x: encode the call-class in the key.
await limiter.check(`tenant:${tenantId}:callClass:${callClass}`, {
maxRequests: callClass === "synthesize" ? 10 : 100,
})Cited source
packages/gateway/src/rateLimit/types.ts—RateLimitercontract.packages/gateway/src/rateLimit/inMemoryLimiter.ts— fixed-window in-process adapter.
Where to go next
Cost events
Per-call cost emission — pairs with rate limiting on the same per-tenant key dimension.
BYOK credential routing
Per-tenant key resolution. Rate-limit keys typically share the same tenant scope as BYOK credentials.
`@pleach/gateway` overview
The Phase A GatewayClient — route(), cost events, family-strict cascade, transport seam.
Gateway · BYOK credential routing
Per-tenant BYOK credential storage and routing — TenantCredentialStore contract, Postgres / Redis / external-secret-manager adapters, and CredentialRoutingMiddleware.
Gateway · Cost events
Per-call cost-event emission — CostEmitter contract, CostMiddleware factory, and four reference adapters (OTel, Postgres, Datadog, Honeycomb).