import { D as Decision, d as Strategy, S as Store, C as Clock, L as Limiter, T as Transform } from './types-DKirIBQt.js'; export { A as ApplyOutcome, F as FailMode, a as Forecast, b as LuaInvocation, c as LuaProgram, R as ReadState, e as StrategyOutcome } from './types-DKirIBQt.js'; export { M as ManualClock, s as systemClock } from './clock-CnB6yaAt.js'; import { T as ThrottleKitError } from './quota-C4WEn9R6.js'; export { N as NotImplementedError, Q as QuotaCadence, a as QuotaOptions, R as RateLimitExceededError, S as StoreUnavailableError, b as ThrottleKitErrorCode, q as quota } from './quota-C4WEn9R6.js'; import { A as AdaptiveConcurrencyOptions, C as ConcurrencyGuard, k as UnifiedAxis, j as UnifiedAdmitter } from './unified-BouIz5EX.js'; export { F as FUSED_GCRA_TOKEN_BUCKET_LUA, a as FluidLpInput, b as FluidLpSolution, c as FusedAdmissionOptions, d as FusedAdmissionResult, e as FusedCostConfig, f as FusedDispatcher, g as FusedRateConfig, L as Lease, U as UnifiedAdmission, h as UnifiedAdmissionOptions, i as UnifiedAdmitOptions, W as WorkloadType, l as adaptiveConcurrency, s as solveFluidLp, u as unifiedAdmission } from './unified-BouIz5EX.js'; import { R as RedisClientLike } from './store-CQjuAFM_.js'; import { PgPoolLike } from './postgres.js'; export { FederatedWeightedFairEscrowLimiter, FederatedWeightedFairEscrowOptions, FederatedWeightedFairEscrowStats, L1Options, LeaseOptions, LeaseSizer, LeaseSizerOptions, PredictiveLeaseSizer, PredictiveLeaseSizerOptions, RegionFairPool, RegionFairPoolOptions, RegionFairPoolStats, TwoTierMode, TwoTierOptions, WeightedFairEscrowLimiter, WeightedFairEscrowOptions, WeightedFairEscrowStats, eoqOptimum, federatedWeightedFairEscrow, leaseSizer, predictiveLeaseSizer, regionFairPool, twoTier, weightedFairEscrow } from './twotier.js'; export { B as BuildRateLimitHeadersOptions, C as ClientIpInput, H as HeaderEmit, T as TrustProxyConfig, b as buildRateLimitHeaders, c as clientIp } from './core-DcpxT2lH.js'; export { E as EnforceOptions, a as EnforceOutcome, b as EnforceResult, c as Enforcer, d as createEnforcer } from './enforce-BpAeVXIS.js'; import './types-DuMrcUCv.js'; /** * The package version — the single source of truth. `src/index.ts` re-exports it as the public * `version`, and the CLI's `--version` reads it, so there is exactly one literal to bump per release. * `test/version-sync.test.ts` asserts `package.json#version` matches it, so the two can't drift. */ declare const version = "1.7.0"; /** * The neutral element for {@link combineDecisions}: a {@link Decision} that * allows unboundedly. Used as the seed for reducing N decisions, and as the * placeholder for an *unused* axis in `unifiedAdmission(...)` (so an admitter * with only `{ rate }` configured is provably indistinguishable from one with * `{ rate, concurrency: ALLOW_FULL, cost: ALLOW_FULL }`). * * `limit` and `remaining` use {@link Number.MAX_SAFE_INTEGER} (not `+Infinity`) * so the algebra produces only integers — preserving bit-identity between the * JavaScript and Redis-Lua execution paths the rest of the library guarantees. * * See `research/bigger-bets/unified/DESIGN.md` §4.1.1.1 (D-U3). */ declare const ALLOW_FULL: Decision; /** * Combine two {@link Decision}s into one — the pure algebra at the heart of * `unifiedAdmission(...)`. Field-by-field aggregation: * * | Field | Rule | Why | * |---|---|---| * | `allowed` | `a.allowed && b.allowed` | AND — both must allow | * | `limit` | `min(a.limit, b.limit)` | binding (smaller) ceiling — what the client should see | * | `remaining` | `min(a.remaining, b.remaining)` | binding remainder — accurate `X-RateLimit-Remaining` | * | `resetAt` | `max(a.resetAt, b.resetAt)` | latest-resolution wait — when *all* axes have refilled | * | `retryAfterMs` | `max(a.retryAfterMs, b.retryAfterMs)` | dominant wait — never under-state the wait | * * Total, pure, and obeys four algebraic laws (proven in * `test/core/combine.test.ts` via fast-check at `numRuns ≥ 500`): * * - **Identity** — `combine(d, ALLOW_FULL) = d` * - **Associativity** — `combine(combine(a,b),c) = combine(a,combine(b,c))` * - **Commutativity** — `combine(a,b) = combine(b,a)` * - **Idempotency** — `combine(d, d) = d` * * Associativity + commutativity together mean: `combineDecisions` extends to * N inputs via `reduce` and the order doesn't change the result, so a * Lua-fused implementation can re-order its checks freely without changing * the decision. Idempotency makes a retried sub-check safe. Identity makes * optional axes free to add. * * See `research/bigger-bets/unified/DESIGN.md` §4.1 (the algebra) and §4.1.1 * (the laws). The decision records are D-U1..D-U3 in §14 of that doc. */ declare function combineDecisions(a: Decision, b: Decision): Decision; interface RateLimitOptions { /** The algorithm to enforce. Defaults across the library favor {@link gcra}. */ strategy: Strategy; /** Where state lives. Defaults to a fresh in-process {@link MemoryStore}. */ store?: Store; /** Injected clock. Defaults to the system clock. */ clock?: Clock; /** Key namespace, so one store can back many independent limiters. */ prefix?: string; } declare function rateLimit(options: RateLimitOptions): Limiter; interface MemoryStoreOptions { /** Injected clock. Defaults to the system clock. */ clock?: Clock; /** * Maximum number of distinct keys before approximate-LRU (CLOCK) eviction kicks in. Unbounded * when omitted — set this on public endpoints so a flood of unique keys can't grow the map * without limit. */ maxKeys?: number; /** * Background sweep interval (ms) that expires idle keys even without traffic. `0` disables the * timer entirely (cleanup becomes purely access-driven), which is the right choice on edge * runtimes. Default 5000. The timer is `unref`'d so it never keeps a Node process alive. */ sweepIntervalMs?: number; /** Timer-wheel tick resolution in ms. Default 1000. */ tickMs?: number; /** Timer-wheel slot count. Default 512. */ wheelSize?: number; } /** * In-process store. Atomicity is free: Node is single-threaded, so a synchronous * read-modify-write cannot interleave — {@link MemoryStore.applySync} needs no locks. The async * {@link MemoryStore.apply} simply resolves the same synchronous result, composing with code that * awaits stores. State is kept as native values (no JSON) so the hot path never serializes. * * When `maxKeys` is set, a CLOCK (second-chance) policy bounds the key cardinality: each access * sets a reference bit, and the hand evicts the first key it finds with the bit clear — O(1) * amortized, with no per-read map reordering, so an adversarial flood of unique keys can't OOM. */ declare class MemoryStore implements Store { #private; constructor(opts?: MemoryStoreOptions); /** Live key count (after sweeping at the current time). */ get size(): number; /** Whether `key` is present and unexpired at the current time. */ has(key: string): boolean; applySync(key: string, transform: Transform, now?: number): R; apply(key: string, transform: Transform): Promise; resetSync(key: string): void; reset(key: string): Promise; close(): Promise; } interface GcraOptions { /** Sustained rate: requests per `periodMs`. */ limit: number; /** The period over which `limit` applies, in ms. */ periodMs: number; /** * Maximum requests admissible instantaneously from a cold/idle state (the burst allowance). * Defaults to `limit`. A cold bucket admits exactly `burst` requests, then paces at `1/T`; * request `burst + 1` is denied. (Note: a request whose `cost` exceeds `burst` can never be * satisfied.) */ burst?: number; } /** * GCRA (Generic Cell Rate Algorithm) — the default strategy. Tracks a single number per key * (the theoretical arrival time), paces traffic smoothly with a configurable burst, and costs * O(1) memory and CPU. See docs/DESIGN-NOTES.md for the verified math and citations. */ declare function gcra(options: GcraOptions): Strategy; interface TokenBucketOptions { /** Bucket capacity: the maximum tokens held, and the largest instantaneous burst. */ capacity: number; /** Sustained refill rate in tokens per second (may be fractional). */ refillPerSec: number; } /** Per-key state: the current token count and the epoch-ms of the last refill. */ interface TokenBucketState { /** Tokens available (fractional; lazily refilled on each check). */ tokens: number; /** Epoch-ms the `tokens` figure was last brought current. */ last: number; } /** * Token bucket — a bucket of `capacity` tokens refilled continuously at `refillPerSec`. A check * succeeds when at least `cost` tokens are present and consumes them; otherwise it is denied and * nothing is consumed. Lazily refilled (no background timer), O(1) memory and CPU. Reports an * explicit token count, unlike GCRA. See docs/DESIGN-NOTES.md for the verified math. */ declare function tokenBucket(options: TokenBucketOptions): Strategy; interface FixedWindowOptions { /** Maximum requests admitted within each window. */ limit: number; /** Window width in ms. Windows are aligned to epoch: `floor(now/windowMs)*windowMs`. */ windowMs: number; } /** Per-key state: the active window's start (epoch-ms) and the count consumed within it. */ interface FixedWindowState { /** Epoch-ms start of the window this count belongs to. */ start: number; /** Units consumed in the window starting at `start`. */ count: number; } /** * Fixed window counter — counts requests within fixed, epoch-aligned windows of `windowMs`, * denying once `limit` is reached. O(1) memory, trivially cheap. * * Documented property: because windows reset on hard boundaries, a client can spend the full * `limit` at the end of one window and another full `limit` at the start of the next, admitting * up to **2×limit** requests across a single boundary. (For smooth pacing use GCRA or token * bucket; for boundary-free accuracy use a sliding window.) As with the other strategies, a * denied request does not consume — `remaining` stays meaningful. */ declare function fixedWindow(options: FixedWindowOptions): Strategy; interface SlidingWindowOptions { /** Maximum units within any trailing `windowMs`. */ limit: number; /** The rolling window length, in ms. */ windowMs: number; /** * Number of sub-buckets the window is divided into. More buckets → smaller approximation error * (bounded by ~1/buckets of the window) at O(buckets) memory. Default 10. `buckets: 1` recovers * the classic single-previous-window weighted estimator. */ buckets?: number; } /** * A fixed ring of `S+1` slots (plain arrays so the JS state JSON-round-trips on the Postgres path). * Slot `tick mod (S+1)` holds that tick's count; `i[p]` records which absolute tick owns the slot, so * a slot from an older lap reads as 0 — mirroring the Lua HASH ring. Replaces a per-check object rebuild. */ interface WindowState { /** Absolute tick index owning each slot (−1 = empty; real ticks are ≥ 0). Length S+1. */ i: number[]; /** Count at each slot. Length S+1. */ n: number[]; } /** * Sliding window counter (sub-bucketed) — near-exact rolling window at any limit with bounded * O(buckets) memory. Error is bounded by one bucket (~1/buckets of the window). The sweet spot * between fixed window (cheap, 2× error) and the exact log (precise, unbounded memory). * See docs/DESIGN-NOTES.md for the estimator and citations. */ declare function slidingWindow(options: SlidingWindowOptions): Strategy; interface SlidingWindowLogOptions { /** Maximum accepted units within any trailing `windowMs`. */ limit: number; /** The rolling window length, in ms. */ windowMs: number; } /** * Sliding window log — exact "N accepted in the trailing window". Stores the timestamp of every * accepted unit and counts those within `windowMs`. O(limit) memory per key; use for low/moderate * limits where precision matters (e.g. 5 password resets / hour). See docs/DESIGN-NOTES.md. */ declare function slidingWindowLog(options: SlidingWindowLogOptions): Strategy; interface LeakyBucketOptions { /** Steady drain rate in units per second (the shaped output rate). */ ratePerSec: number; /** Maximum time a request may wait in the queue before it is rejected instead of delayed. */ maxQueueMs: number; /** Where the next-departure timestamp lives. Defaults to a fresh {@link MemoryStore}. */ store?: Store; /** Injected clock. Defaults to the system clock. */ clock?: Clock; /** Key namespace. */ prefix?: string; } /** The outcome of reserving a slot in the leaky bucket. */ interface Reservation { /** Whether a slot was granted (within `maxQueueMs`). */ accepted: boolean; /** * When accepted, how long to wait before proceeding so output is paced to `ratePerSec`. When * rejected, an advisory hint for how long until the queue would have room. */ delayMs: number; } /** Thrown by {@link Shaper.schedule} when the request would wait longer than `maxQueueMs`. */ declare class QueueFullError extends ThrottleKitError { readonly retryAfterMs: number; constructor(retryAfterMs: number); } /** A traffic shaper: paces accepted requests to a fixed rate, rejecting only when the queue is full. */ interface Shaper { /** Reserve a slot. Resolves with the wait time; never sleeps. */ reserve(key: string, cost?: number): Promise; /** Synchronous reserve (requires a synchronous store, e.g. {@link MemoryStore}). */ reserveSync(key: string, cost?: number): Reservation; /** Reserve and wait: resolves after the paced delay, or rejects with {@link QueueFullError}. */ schedule(key: string, cost?: number): Promise; /** Forget a key's queue position. */ reset(key: string): Promise; } /** * Leaky bucket (shaper) — smooths bursty input to a steady `ratePerSec` by scheduling each * accepted request a little later, rejecting only when the wait would exceed `maxQueueMs`. Ideal * for pacing outbound calls to a third-party budget. The next-departure recurrence is the same as * GCRA's TAT; GCRA *rejects* past its tolerance, the shaper *waits*. See docs/DESIGN-NOTES.md. */ declare function leakyBucket(options: LeakyBucketOptions): Shaper; /** One node's heartbeat report to the {@link ConcurrencyCoordinator}. */ interface ConcurrencyReport { /** Logical shared-backend key. Nodes sharing a backend MUST use the same key. */ key: string; /** Unique-per-process node identity. */ nodeId: string; /** This node's locally-inferred ceiling (its private adaptiveConcurrency `limit`). */ lLocal: number; /** This node's current in-flight count — the demand signal. Equal-split (the default * allocation) uses it only in the occupancy cap; `allocation:"demand-proportional"` * (TK-1403, opt-in) additionally uses it to size each node's TARGET — a saturated node * (`inflight ≥ share`) is "hungry" and claims released budget, an under-occupied node * drains to a probe slot. */ inflight: number; /** Lease expiry, epoch-ms. The coordinator MUST treat any node with * `expiresAt < now` as departed and reclaim its share. */ expiresAt: number; /** * ACKNOWLEDGED HANDOFF (D-DAC-19, opt-in). The node's strictly-increasing * heartbeat sequence (its private `heartbeatSeq`). Lets a handoff coordinator * IGNORE a reordered/stale heartbeat (one with `seq ≤` the freshest it has * processed for this node) so concurrent in-flight heartbeats can't regress its * committed-grant bookkeeping. Optional & additive (DR-14): a non-handoff * coordinator ignores it; a handoff coordinator treats `undefined` as "always * fresh". Sampled atomically with {@link inflight} and {@link appliedGen}. */ seq?: number; /** * ACKNOWLEDGED HANDOFF (D-DAC-19, opt-in). The grant GENERATION the node's guard * is currently ENFORCING (the {@link ConcurrencyGrant.gen} it has applied). A * handoff coordinator uses it to detect "the peer has caught up to the current * value" and stop reserving a superseded higher grant. MUST be sampled at the * SAME instant as {@link inflight} (a torn snapshot — fresh gen, stale inflight — * is unsound; the guard reads both synchronously). Optional & additive: absent ⇒ * the coordinator never resets its reserve floor for this peer (the SAFE, * over-reserving direction — used for a not-yet-upgraded guard). */ appliedGen?: number; } /** The coordinator's grant back to one node for the next heartbeat window. */ interface ConcurrencyGrant { /** This node's allocated ceiling. `acquire()` admits while `inflight < share`. */ share: number; /** Current fleet-wide inferred limit (telemetry). */ lGlobal: number; /** Count of live nodes the coordinator aggregated over (telemetry / equal-split transparency). */ nodes: number; /** * ACKNOWLEDGED HANDOFF (D-DAC-19, opt-in). The GENERATION of this grant — * incremented by the coordinator ONLY when the granted {@link share} VALUE * changes (NOT once per heartbeat). The guard applies grants monotonically and * echoes the gen it enforces back as {@link ConcurrencyReport.appliedGen}, so the * coordinator learns when the peer has applied the current value and can release * the budget a now-superseded higher grant was reserving. Present only from a * coordinator running acknowledged handoff; the guard treats `undefined` as 0. */ gen?: number; } /** * Owns the shared `L_global` and parcels it into per-node shares. The * event-release sibling of {@link GlobalCoordinator} (federation): same * "central authority leases sub-budgets to N participants" shape, but the * lease is renewed by heartbeat (liveness) and reclaimed by TTL, not reset by * a wall-clock window. See DESIGN §3 + §9. */ interface ConcurrencyCoordinator { /** * Heartbeat + report + (re)lease in one round-trip. The coordinator: * 1. upserts this node's {lLocal, inflight, expiresAt}; * 2. evicts every node whose `expiresAt < now`; * 3. recomputes `L_global = aggregate(live nodes' lLocal)`; * 4. equal-splits `L_global` across the live nodes (§6) and returns this * node's share. * Idempotent per `nodeId` within a heartbeat. MAY reject with * `StoreUnavailableError` on unreachability. */ heartbeat(report: ConcurrencyReport): Promise; /** Voluntary departure: drop `nodeId` and reclaim its share now (don't wait for TTL). * Best-effort, idempotent. */ leave(args: { key: string; nodeId: string; }): Promise; /** Optional liveness probe; defaults to always-healthy. */ isHealthy?(): Promise; } /** * Distributed adaptive concurrency — the 0.10.0 primitive (bet #80, TK-1315). * * `adaptiveConcurrency()` infers a concurrency ceiling **per process** from * locally observed RTT. When N processes front one shared backend, N * independent limiters each infer a ceiling for the *whole* backend and * collectively admit up to `Σ Lᵢ` — N× the backend's true capacity. The * adaptive limiter that was supposed to *prevent* overload now *causes* it * under fan-out. * * `distributedAdaptiveConcurrency()` closes that gap: a drop-in * {@link ConcurrencyGuard} (so every 0.9.2 adapter picks it up unchanged) that * keeps the fleet's total in-flight count under one cooperatively-inferred * global ceiling. It is a **composition** of two already-shipped ideas: * * - a PRIVATE in-process `adaptiveConcurrency` owns RTT, `L_local`, in-flight * tracking, and release idempotency (Mechanism 1 — capacity estimation); and * - a {@link ConcurrencyCoordinator} folds every live node's `L_local` into one * `L_global` and equal-splits it into per-node `share`s (Mechanism 2 — * capacity allocation, federation relabeled). * * The guard delegates `acquire()`/`release()` to the private guard and only * *tightens the gate* by the coordinator-supplied `share`: the effective * ceiling is `min(share, local.limit)` (D-DAC-5 / D-DAC-6). Because both terms * are `≤ local.limit`, whenever the outer gate admits, `local.acquire()` is * guaranteed to return `ok: true` (§4.2 proof). * * See `research/bigger-bets/distributed-adaptive-concurrency/DESIGN.md` * (§4.2, §5.2, §6, §8) — this file transcribes that locked design. */ /** Injectable repeating timer (so tests drive heartbeats deterministically). */ interface HeartbeatScheduler { schedule(fn: () => void, everyMs: number): { cancel(): void; }; /** * One-shot timer, used ONLY by the eager-handoff path (D-DAC-20) to fire a * debounced off-cycle heartbeat `delayMs` from now. Optional for backward * compatibility: a scheduler without it works for the periodic-only default, * but `eagerHandoff: true` REQUIRES it (construction throws otherwise). The * default scheduler implements it with an `unref`'d `setTimeout`. */ setTimer?(fn: () => void, delayMs: number): { cancel(): void; }; } interface DistributedAdaptiveConcurrencyOptions { /** The cross-node coordinator that owns `L_global`. */ coordinator: ConcurrencyCoordinator; /** Unique-per-process identity. REQUIRED (no default — collisions corrupt the aggregate). */ nodeId: string; /** Shared-backend key. Nodes fronting the same backend MUST match. Default "". */ key?: string; /** Forwarded verbatim to the private `adaptiveConcurrency`. Default {}. */ local?: AdaptiveConcurrencyOptions; /** Heartbeat / lease-renewal period in ms — the `heartbeat_T`. Default 1000. */ heartbeatMs?: number; /** Lease TTL handed to the coordinator (`expiresAt = now + leaseTtlMs`). * MUST exceed `heartbeatMs` so a single slow heartbeat doesn't drop the node. * Default `2 * heartbeatMs`. */ leaseTtlMs?: number; /** Behavior when `coordinator.heartbeat()` throws. Default "fail-closed". */ onCoordinatorOutage?: "fail-closed" | "local-only"; /** * EAGER (event-driven) HANDOFF — opt-in, default `false` (D-DAC-20). When `true`, * the guard fires **off-cycle** heartbeats the instant local state shows the * coordinator's allocation is stale, instead of waiting for the next periodic * tick — collapsing handoff ramp latency from ~2 heartbeats toward the physical * floor (drain + one round-trip) WITHOUT loosening any bound. Three triggers, * all guard-side (no coordinator/wire change; safe by the existing exhaustive * model — an off-cycle beat is just a `Report`/`Reallocate` at a different time): * - PULL: a node capped BELOW its fair share (`share < ⌊lGlobal/nodes⌋`, computed * from already-returned telemetry) re-beats to pick up budget peers are freeing; * - PUSH: an incumbent whose in-flight drains to ≤ its (lowered) share re-beats to * report the freed capacity so peers can claim it; * - ACK: after applying a grant whose generation changed (a lowered share), the * node re-beats to confirm it — under acknowledged handoff the coordinator * reserves the node's un-acked-high grant until this ack lands. * Off-cycle beats are debounced to ≥ {@link minHeartbeatMs} apart (coalesced through * one pending timer), so steady state adds ZERO beats — the burst is transient, * during a rebalance only. Pairs with `acknowledgedHandoff` for a hard * `Σ inflight ≤ L_global` bound at near-floor ramp. REQUIRES `scheduler.setTimer`. */ eagerHandoff?: boolean; /** * Minimum spacing between off-cycle eager heartbeats, in ms (the debounce floor * that bounds eager coordinator load). Only used when `eagerHandoff: true`. * Smaller ⇒ faster ramp + more beats during a transient; larger ⇒ the reverse. * Default `max(1, round(heartbeatMs / 10))`, clamped to `≤ heartbeatMs`. */ minHeartbeatMs?: number; /** * SELF-FENCING — close the lease-expiry / partition overshoot (D-DAC-21). Default * `true` under `fail-closed`, `false` under `local-only` (which opts into serving * through an outage). A partitioned node cannot heartbeat, but in 0.10.x kept * ADMITTING against its last-known share until a beat *threw* — and a partition * usually HANGS rather than throwing, so the node over-admitted for the whole * partition while the coordinator reassigned its budget (Σ inflight > L_global). * Self-fencing enforces the lease on the node's OWN clock: it stops admitting at * `lastSuccessfulBeatExpiresAt − fenceSafetyMargin`, strictly BEFORE the * coordinator's reclaim, so peers never ramp into budget the node still holds. * A healthy node never fences **provided `leaseTtlMs − heartbeatMs` comfortably * exceeds the heartbeat round-trip** — the default `leaseTtlMs = 2·heartbeatMs` * gives a half-period of slack; do not set `leaseTtlMs` only marginally above * `heartbeatMs` or an on-schedule node can fence transiently. Assumption: node↔ * coordinator clock divergence (offset + drift accumulated over one lease) ≤ * {@link fenceSafetyMargin} (the standard lease assumption; FLP/CAP make some such * assumption unavoidable without backend fence tokens — which don't fit a fungible * counting budget anyway). Adds one clock read per `acquire()` while on (the * time-based fence check); set `selfFence: false` for the 0.10.x throw-only path. * See `HARD-ASYNC-BOUND.md` §8, the timed gate `distributed-self-fence-model.test.ts`. */ selfFence?: boolean; /** * How long BEFORE the reported lease expiry the node self-fences, in ms — the slack * that absorbs node↔coordinator clock divergence. It MUST be ≥ your max clock OFFSET * **plus** the DRIFT accumulated over one `leaseTtlMs` (NTP keeps both tiny — a few * ms — so the default has orders of magnitude of headroom), or the node can still be * admitting when the coordinator reclaims. Only used when `selfFence` is on. Default * `max(1, round((leaseTtlMs − heartbeatMs) / 2))` — the midpoint of the grace period * between one missed beat and lease expiry, so a single slow beat never fences a * healthy node. */ fenceSafetyMargin?: number; /** * Called ONCE when the node enters the self-fenced state (it has lost contact and * its lease is about to be reclaimed). Use it to ABORT in-flight work (e.g. * `AbortController.abort()`): self-fencing stops NEW admits, and aborting drains * the already-accepted occupancy before the reclaim, closing the overshoot fully * under the clock-skew assumption. Non-cancellable in-flight instead needs the * margin to cover its max duration (see the gate). Fires again on a later * fence episode if the node recovers and re-partitions. */ onFenced?: () => void; /** Injectable clock. Default systemClock. */ clock?: Clock; /** Injectable scheduler. Default a setInterval-based timer (unref'd). */ scheduler?: HeartbeatScheduler; } /** A {@link ConcurrencyGuard} plus distributed lifecycle. */ interface DistributedConcurrencyGuard extends ConcurrencyGuard { /** Force a heartbeat now (report L_local, refresh share). Resolves when the * round-trip lands. Normally driven by the internal timer; exposed for tests * and graceful pre-shutdown sync. Never throws (outage → outage policy). */ heartbeat(): Promise; /** Stop the timer and `leave()` the fleet. Idempotent. */ close(): Promise; /** Distributed stats snapshot (extends the base `stats()`). */ stats(): { limit: number; inflight: number; rttNoload: number; lastRtt: number; share: number; lGlobal: number; nodes: number; /** Whether the node is currently SELF-FENCED (D-DAC-21): it has lost contact and * passed its local lease deadline, so it admits nothing until a beat lands again. */ fenced: boolean; }; } /** * Distributed adaptive concurrency guard. Composes a private * {@link adaptiveConcurrency} with a {@link ConcurrencyCoordinator}: the private * guard owns RTT/`L_local`/in-flight/idempotency, and the coordinator supplies * the per-node `share` that tightens the gate to `min(share, local.limit)`. * * Quick start: * * import { distributedAdaptiveConcurrency, TestConcurrencyCoordinator } from "throttlekit"; * * const coordinator = new TestConcurrencyCoordinator(); * const guard = distributedAdaptiveConcurrency({ * coordinator, * nodeId: process.env.HOSTNAME ?? "node-1", * key: "inference-cluster", * }); * // optionally gate startup on the first share: * await guard.heartbeat(); * const lease = guard.acquire(); * if (!lease.ok) reject503(); * else try { await work(); } finally { lease.release(); } * // on shutdown: * await guard.close(); */ declare function distributedAdaptiveConcurrency(options: DistributedAdaptiveConcurrencyOptions): DistributedConcurrencyGuard; /** * `TestConcurrencyCoordinator` — an in-memory, deterministic `ConcurrencyCoordinator` for * tests + examples. The full heartbeat-aggregate-cap compute lives in the shared pure * {@link applyHeartbeat} (`heartbeat-core`), so this coordinator and * `PostgresConcurrencyCoordinator` are STRUCTURALLY identical (one source of truth); * `RedisConcurrencyCoordinator` keeps its own Lua transcription, held to the same reference * by the dual-path conformance test. * * The event-release sibling of `TestCoordinator` (federation): same "central authority leases * sub-budgets to N participants" shape, but the lease is renewed by heartbeat (liveness) and * reclaimed by TTL, not reset by a wall-clock window. See DESIGN §3 + §9; the safety rationale * (the occupancy CAP — D-DAC-17/18, the handoff reserve floor D-DAC-19, and the allocation * TARGET D-DAC-9/22) is documented on {@link applyHeartbeat}. * * No timers; no I/O. Deterministic under an injected clock. */ /** Options for {@link TestConcurrencyCoordinator}. */ interface TestConcurrencyCoordinatorOptions { /** * Fleet-wide aggregation policy (§7). `"median"` is the lower median of the live nodes' * `lLocal`; `"min"` is the most-stressed node's view. NEVER `sum` (§7 / D-DAC-10). * Defaults to `"median"`. */ aggregate?: "min" | "median"; /** * Injected clock. Expiry is compared against `clock.now()`. Defaults to {@link systemClock}. */ clock?: Clock; /** * ACKNOWLEDGED HANDOFF (D-DAC-19) — opt-in, default `false`. When `true`, the cap reserves * each peer's MAX UN-ACKNOWLEDGED grant (via the grant-generation echo) unioned with its * reported in-flight — making `Σ inflight ≤ L_global` a HARD instantaneous bound even under * async grant-reply + reporting lag (TLA⁺ `GaleHeartbeatHandoff` + BFS twin TK-1330), at the * cost of ramp latency. When `false` (default), the cap is the 0.10.0 occupancy cap * `max(share, inflight)` (D-DAC-18). All nodes on a key MUST agree (like {@link aggregate}). */ acknowledgedHandoff?: boolean; /** * Capacity ALLOCATION rule (D-DAC-9 / TK-1403) — how `L_global` is split into per-node * TARGETs. `"equal-split"` (**default**, behavior-preserving) gives every live node ≈`L/N` * regardless of use; `"demand-proportional"` lets a SATISFIED node (`inflight < share`) * drain to its occupancy + 1 probe slot, releasing the rest, which the cap re-grants to * HUNGRY nodes (`inflight ≥ share`) — +25–50pp utilization under skew, 0 regression when * balanced. SAFETY IS UNAFFECTED: the occupancy cap enforces both bounds for ANY target * (§6/§9.4). Starvation-free when `L_global ≥ N`. All nodes on a key MUST agree. */ allocation?: "equal-split" | "demand-proportional"; } declare class TestConcurrencyCoordinator implements ConcurrencyCoordinator { #private; constructor(options?: TestConcurrencyCoordinatorOptions); /** Simulate a coordinator partition. `heartbeat()` throws until `setHealthy(true)`. */ setHealthy(healthy: boolean): void; /** * For tests: snapshot the current aggregate + the per-node shares the coordinator has * actually granted (NOT a fresh stateless re-split). `Σ shares ≤ lGlobal` holds whenever * `lGlobal` has been stable; it is the budget the global bound is checked against. */ peek(key: string): { lGlobal: number; nodes: number; shares: Record; }; heartbeat(report: ConcurrencyReport): Promise; leave(args: { key: string; nodeId: string; }): Promise; isHealthy(): Promise; } /** * `RedisConcurrencyCoordinator` — the production-ready * {@link ConcurrencyCoordinator}. Backed by a single global Redis instance; * the event-release sibling of `RedisCoordinator` (federation). One Lua script * does heartbeat-aggregate-split atomically (DESIGN §10.2) — no read-modify- * write race. * * Layout — one HASH per coordinator key (`conc:`), one field per * live node: * * field = nodeId * value = " " (space-joined integers) * * The stored `share` is the value this node's last grant returned; the script * carries it forward across heartbeats so the budget cap (D-DAC-17) can see what * every other live node currently holds. The HASH carries a GC PEXPIRE derived * from the longest-lived member's `expiresAt`, so a fully-abandoned key * self-drops with no extra bookkeeping. Per-node eviction is exact * (`expiresAt < now`) inside the script, independent of that GC ttl. * * Heartbeat is one EVALSHA per round-trip (EVAL fallback on NOSCRIPT). The * script: upserts self (carrying forward its stored `share`, 0 if first-seen), * evicts expired fields, aggregates the live `lLocal` values into `lGlobal` * (`min` or lower-`median`, per the `aggregate` arg — DESIGN §7), computes this * node's equal-split TARGET across the sorted live nodeIds (DESIGN §6), CAPS the * grant at the budget no other live node is currently HOLDING * (`max(0, min(target, lGlobal − Σ_other max(share, inflight)))` — D-DAC-17 for * the `share` term / D-DAC-18 for the `inflight` term), stores the capped share * back, and returns this node's `{share, lGlobal, N}`. All arithmetic is integer. * * This is the Lua transcription of `TestConcurrencyCoordinator`'s reference * algorithm (DESIGN §10.1); the two MUST return identical `{share, lGlobal, * nodes}` for identical report sequences (the dual-path conformance test). * * Mirrors `RedisCoordinator`'s client abstraction (`RedisClientLike`/`#eval`), * its EVALSHA-with-EVAL-fallback load pattern, and its `StoreUnavailableError` * mapping (DESIGN §10.2). */ interface RedisConcurrencyCoordinatorOptions { /** An `ioredis` (or compatible) client. Use the adapters in `throttlekit/redis` for other clients. */ client: RedisClientLike; /** * Fleet-wide aggregation rule folding live nodes' `lLocal` into `L_global` * (DESIGN §7). `"median"` (default) takes the lower median; `"min"` takes the * minimum (the conservative extreme). Every node fronting a key MUST agree on * one rule — that is why it lives on the coordinator (D-DAC-8). */ aggregate?: "min" | "median"; /** Redis key prefix. Default `"tk:fed"`. */ prefix?: string; /** * ACKNOWLEDGED HANDOFF (D-DAC-19) — opt-in, default `false`. The Lua twin of * {@link TestConcurrencyCoordinatorOptions.acknowledgedHandoff}: the cap reserves * each peer's MAX UN-ACKNOWLEDGED grant (via the grant-generation echo) unioned * with its reported in-flight, making `Σ inflight ≤ L_global` a HARD instantaneous * bound under async lag (TLA⁺ `GaleHeartbeatHandoff` + BFS twin TK-1330), at the * cost of ramp latency. `false` keeps the 0.10.0 occupancy cap (D-DAC-18). All * nodes/coordinators on a key MUST agree (like {@link aggregate}); enable only once * every guard echoes `appliedGen`. The per-field value widens from 4 to 7 ints when * enabled (additive; the parser reads legacy 4-int values as gen/maxSeq/high = 0). */ acknowledgedHandoff?: boolean; /** * Use the Redis server clock (`TIME`) for eviction's `now`. Default `true` (node clock skew is * then irrelevant — every node's heartbeat evicts peers against ONE clock). With `false` the * calling node's `Date.now()` is used, so a node whose clock runs ahead can prematurely evict a * healthy peer (whose `expiresAt` was stamped on its own slower clock) and reclaim its budget, * breaking `Σ inflight ≤ L_global`. Matches `PostgresConcurrencyCoordinator.useServerTime` and * `RedisStore.useServerTime`. Every node fronting a key MUST agree (it changes the eviction * anchor), like {@link aggregate}. Set `false` in deterministic tests that pin `expiresAt` far in * the future. (NOTE: `expiresAt` is still stamped on each node's local clock, like Postgres.) */ useServerTime?: boolean; /** * Capacity ALLOCATION rule (D-DAC-9 / TK-1403) — the Lua twin of * {@link TestConcurrencyCoordinatorOptions.allocation}. `"equal-split"` (**default**, * behavior-preserving) splits `L_global` ≈`L/N`; `"demand-proportional"` lets satisfied * nodes (`inflight < share`) drain to occupancy + 1 probe slot and re-grants the released * budget to hungry (`inflight ≥ share`) nodes — +25–50pp utilization under skew (TK-1403a * gate), zero regression when balanced. SAFETY IS UNAFFECTED: the occupancy cap is * unchanged; only the TARGET changes, and §6/§9.4 prove both bounds hold for ANY target. * Every node keeps a ≥1 probe slot (starvation-free). All nodes/coordinators on a key MUST * agree (like {@link aggregate}). Default `"equal-split"`. */ allocation?: "equal-split" | "demand-proportional"; } declare class RedisConcurrencyCoordinator implements ConcurrencyCoordinator { #private; constructor(options: RedisConcurrencyCoordinatorOptions); heartbeat(report: ConcurrencyReport): Promise; leave(args: { key: string; nodeId: string; }): Promise; } /** * `PostgresConcurrencyCoordinator` — a `ConcurrencyCoordinator` backed by a single Postgres * primary; the event-release sibling of the federation `PostgresCoordinator` (0.8.4) and a * drop-in alternative to `RedisConcurrencyCoordinator` (TK-1402). * * It runs the SAME heartbeat-aggregate-cap compute as the in-memory reference — the shared * pure {@link applyHeartbeat} (`heartbeat-core`) — inside one `pg_advisory_xact_lock` * transaction, so it is STRUCTURALLY conformant with `TestConcurrencyCoordinator` (not a * separate transcription). Per heartbeat: lock the key, load its node rows, run * `applyHeartbeat` (upsert self, evict expired, aggregate, TARGET, CAP, record), then persist * the post-state — delete the rows it evicted and upsert self. No other live node's row is * touched (the compute mutates only self + evictions), mirroring the Redis Lua's write shape. * * Layout — one row per `(key, node_id)` in `tk_conc_state`: * * key, node_id : PK * l_local, inflight, expires_at, share : the live report + granted share * committed_gen, max_seq, unacked_high : acknowledged-handoff bookkeeping (D-DAC-19) * updated_at : last-touch epoch-ms (GC) * * Server-time anchoring via `clock_timestamp()` (NOT `current_timestamp`, which is the txn * start time). **SPOF**: a single Postgres primary is a single point of failure — mitigate * with synchronous replication + automated failover; during failover, guards fall back to * their `onCoordinatorOutage` mode. See research/.../distributed-adaptive-concurrency/DESIGN.md * §5.3 + §14.2. */ interface PostgresConcurrencyCoordinatorOptions { /** A `pg` (node-postgres) pool — or any `PgPoolLike`. ThrottleKit holds it but never closes it. */ pool: PgPoolLike; /** * Fleet-wide aggregation rule folding live nodes' `lLocal` into `L_global` (§7). `"median"` * (default) takes the lower median; `"min"` the minimum. Every node on a key MUST agree (D-DAC-8). */ aggregate?: "min" | "median"; /** * Capacity ALLOCATION rule (D-DAC-9 / D-DAC-22). `"equal-split"` (default) or * `"demand-proportional"` (skew-aware). All nodes/coordinators on a key MUST agree. */ allocation?: "equal-split" | "demand-proportional"; /** * ACKNOWLEDGED HANDOFF (D-DAC-19) — opt-in, default `false`. Reserves each peer's max * un-acknowledged grant, making `Σ inflight ≤ L_global` a hard async bound at a ramp-latency * cost. All nodes/coordinators on a key MUST agree; enable only once every guard echoes `appliedGen`. */ acknowledgedHandoff?: boolean; /** Postgres table name (created on first use). Default `"tk_conc_state"`. */ tableName?: string; /** Key prefix prepended to every coordinator key. Default `"tk:fed"`. */ prefix?: string; /** Background-GC sweep interval in ms. Default 60_000. Pass 0 to disable the timer. */ gcIntervalMs?: number; /** Dormancy threshold for GC: rows untouched + expired for this long are deleted. Default 24h. */ gcRetentionMs?: number; /** * Use Postgres `clock_timestamp()` for eviction's `now`. Default `true` (node clock skew is * then irrelevant). Set `false` in deterministic tests that pin `expiresAt` far in the future. */ useServerTime?: boolean; } declare class PostgresConcurrencyCoordinator implements ConcurrencyCoordinator { #private; constructor(options: PostgresConcurrencyCoordinatorOptions); heartbeat(report: ConcurrencyReport): Promise; leave(args: { key: string; nodeId: string; }): Promise; isHealthy(): Promise; /** Stop the background GC interval. Idempotent. */ close(): void; } /** * PII-safe key hashing. Rate-limit keys are often raw identifiers (IPs, user ids, API keys); * hashing them with a server secret before they reach the store means the backing store (a shared * Redis, say) never holds the raw value — useful for GDPR posture and multi-tenant deployments. * See THROTTLEKIT.md §14. * * HMAC (keyed hash) rather than a bare digest so the mapping isn't a public rainbow-table lookup: * without the secret an attacker can't precompute `hash(ip)` for every IP. */ /** * Hash `raw` with `secret` using HMAC-SHA-256, returned as a 64-character lowercase hex string. * Deterministic: the same `(raw, secret)` always yields the same digest. */ declare function hashKey(raw: string, secret: string): string; /** * Build a keyer bound to one `secret`. Handy as a `key`-deriving step: `hmacKeyer(secret)(ip)`. * The secret is captured once so the hot path is a single `createHmac` call. */ declare function hmacKeyer(secret: string): (raw: string) => string; /** One axis of a multi-dimensional limit. */ interface Dimension { /** Derive this dimension's key from the request context (e.g. the client IP). */ key: (ctx: Ctx) => string; /** The algorithm enforced on this dimension. */ strategy: Strategy; /** Optional per-dimension weight; multiplied by the global cost. Default 1. */ cost?: (ctx: Ctx) => number; } type Dimensions = Record>; /** A composite of named dimensions plus a combine mode. Build with {@link all} / {@link any}. */ interface MultiStrategy { readonly mode: "all" | "any"; readonly dimensions: Dimensions; } /** Allow only if **every** dimension allows; consume nothing unless all allow (no partial consume). */ declare function all(dimensions: Dimensions): MultiStrategy; /** Allow if **any** dimension allows; consume only the dimensions that individually allow. */ declare function any(dimensions: Dimensions): MultiStrategy; interface MultiRateLimitOptions { strategy: MultiStrategy; store?: Store; clock?: Clock; prefix?: string; } interface MultiLimiter { check(ctx: Ctx, cost?: number): Promise; /** Synchronous check; requires a synchronous store (e.g. MemoryStore). */ checkSync(ctx: Ctx, cost?: number): Decision; reset(ctx: Ctx): Promise; } /** * Multi-dimensional limiter: evaluate per-IP ∧ per-user ∧ per-route (etc.) atomically. On a * synchronous store it reads all dimensions, decides, then commits all-or-none in one * uninterrupted turn (no partial consume); on Redis it fuses every dimension into a single Lua * round trip. See THROTTLEKIT.md §9. */ declare function multiRateLimit(options: MultiRateLimitOptions): MultiLimiter; /** * A detached, transportable copy of a Count-Min Sketch's state — the counter table plus the total * mass added. Produced by {@link CountMinSketch.snapshot} (in-process) or * {@link sketchSnapshotFromBytes} (decoded from the wire), and folded into another sketch with * {@link CountMinSketch.mergeSnapshot}. */ interface SketchSnapshot { /** Columns per row. Must match the target sketch to merge. */ readonly width: number; /** Rows (independent hashes). Must match the target sketch to merge. */ readonly depth: number; /** Total mass added into the source sketch (the `N` in the `epsilon·N` error bound). */ readonly total: number; /** A flat `depth*width` counter table — a copy, safe to transfer or own. */ readonly counters: Uint32Array; } /** Options for {@link sketchRateLimit}. */ interface SketchRateLimitOptions { /** Maximum requests admitted per key within each window. */ limit: number; /** Window width in ms. Windows are aligned to epoch: `floor(now/windowMs)*windowMs`. */ windowMs: number; /** * Additive accuracy: the sketch overestimates a key's count by at most `epsilon * N` (with `N` * the total admitted mass in the window) with probability `>= 1 - delta`. Smaller is more * accurate but uses more memory (`width = ceil(e/epsilon)`). Default `0.01`. */ epsilon?: number; /** * Failure probability for the {@link SketchRateLimitOptions.epsilon} bound. Smaller is more * reliable but uses more memory (`depth = ceil(ln(1/delta))`). Default `0.001`. */ delta?: number; /** * Use the Estan–Varghese conservative-update rule (tighter overestimate). Default `true`. The * never-over-admit guarantee holds either way. */ conservative?: boolean; /** * 32-bit hash seed. **Defaults to a per-instance random value**, so an attacker cannot precompute * keys that collide with a victim and grief it into false denial. Pass a fixed `seed` only for * reproducible tests — pinning it re-enables offline collider precomputation. */ seed?: number; /** Injected clock. Defaults to the system clock. */ clock?: Clock; } /** A windowed, fixed-memory approximate rate limiter backed by a {@link CountMinSketch}. */ interface SketchRateLimiter { /** Synchronous, zero-`await` check for `key` with the given `cost` (default 1). */ checkSync(key: string, cost?: number): Decision; /** Promise-returning form of {@link SketchRateLimiter.checkSync}; resolves synchronously. */ check(key: string, cost?: number): Promise; /** Zero the sketch and drop the current window. */ reset(): void; /** Number of counters backing the sketch (`depth * width`). Independent of key count. */ readonly capacity: number; } /** * Approximate, **fixed-memory** rate limiter over an unbounded key universe. * * Unlike the per-key strategies (which store one record per active key), this keeps a single * {@link CountMinSketch} of `O(1/epsilon · ln(1/delta))` counters regardless of how many distinct * keys are seen — ideal for shedding load from millions of distinct IPs in a volumetric attack, * where per-key state would itself be the memory-exhaustion vector. * * Windowing is fixed-window, epoch-aligned exactly like {@link fixedWindow}: the first check at or * after `windowStart + windowMs` rolls the window — zeroing the counter table and realigning * `windowStart` to `floor(now/windowMs)*windowMs`. * * **Check-before-add** is what makes the safety guarantee hold (and is mandatory, because a CMS * cannot be decremented): we read `e = estimate(key)`, admit iff `e + cost <= limit`, and only then * `add(key, cost)`. A denial adds nothing. * * **The guarantee.** Because `estimate >= trueCount` always, `allowed` implies the *true* admitted * count for the key (including this request) is `<= limit`: the limiter **never over-admits** — a * hard, non-probabilistic property. Its only error is in the safe direction: it may deny a key * slightly early once collisions inflate its estimate. By the CMS bound that early-denial slack is * `<= epsilon * N` with probability `>= 1 - delta`. Over-denying (never over-admitting) is exactly * the right bias for DDoS and abuse protection. * * @experimental Excluded from the 1.x SemVer guarantee (may change in a minor). See STABILITY.md. */ declare function sketchRateLimit(options: SketchRateLimitOptions): SketchRateLimiter; /** Decode bytes produced by {@link MergeableSketch.toBytes} back into a {@link SketchSnapshot}. */ declare function sketchSnapshotFromBytes(bytes: Uint8Array): SketchSnapshot; /** Options for {@link mergeableSketch}. */ interface MergeableSketchOptions { /** * Additive accuracy: a key's global estimate exceeds its true global count by at most * `epsilon * N` (with `N` the total merged mass) with probability `>= 1 - delta`. Default `0.01`. */ epsilon?: number; /** Failure probability for the {@link MergeableSketchOptions.epsilon} bound. Default `0.001`. */ delta?: number; /** * 32-bit hash seed shared by every node in the cluster. Defaults to a fixed value so peers merge * out of the box; all merging nodes MUST use the same seed (as they already must use the same * `epsilon`/`delta`), since a merge of differently-hashed sketches is meaningless. */ seed?: number; } /** * A mergeable, serializable Count-Min Sketch for **cluster-wide** frequency estimation in fixed * memory. */ interface MergeableSketch { /** Add `count` (default 1) to `key`'s local tally; returns the new local estimate. */ add(key: string, count?: number): number; /** Current estimate for `key` over everything added or merged so far. Never underestimates. */ estimate(key: string): number; /** Total mass added/merged so far (the `N` in the `epsilon·N` bound). */ readonly total: number; /** Counter count backing the sketch (`depth*width`) — fixed, independent of key count. */ readonly capacity: number; /** A detached copy of this sketch's state, to ship to peers or fold in elsewhere. */ snapshot(): SketchSnapshot; /** Compact little-endian bytes of {@link MergeableSketch.snapshot}, for cross-node transport. */ toBytes(): Uint8Array; /** Fold a peer's snapshot into this sketch (exact element-wise add; throws on dimension mismatch). */ merge(snapshot: SketchSnapshot): void; /** Zero the sketch. */ reset(): void; } /** * A **mergeable** Count-Min Sketch for cluster-wide heavy-hitter detection. * * Each node keeps its own fixed-memory sketch of the traffic it sees ({@link MergeableSketch.add}). * Because CMS counters are linear, periodically summing the nodes' sketches * ({@link MergeableSketch.merge}, fed by {@link MergeableSketch.snapshot} / {@link MergeableSketch.toBytes} * over whatever transport you already have) yields a sketch of the *union* of all their streams — * a global per-key frequency estimate in the same fixed footprint, regardless of node count or key * cardinality. This sketch is **plain** (non-conservative), so a merge is *exact*: identical to one * sketch that had seen every node's stream. The estimate never underestimates, so threshold shedding * (e.g. "shed any key whose global estimate exceeds X this window") never misses a true heavy hitter. * * **Honest scope.** This is an *eventually-consistent* global **estimator** for detection and * best-effort shedding — each node acts on its most recent merged view. It is **not** a * strongly-consistent global limiter and gives no hard never-over-admit guarantee across the cluster; * for an exact shared limit use {@link rateLimit} over a Redis/Postgres store, or `twoTier`. The * library provides the mergeable data structure and the math, not the merge schedule or transport — * those stay yours (gossip, a periodic push to a coordinator, etc.). * * @experimental Excluded from the 1.x SemVer guarantee (may change in a minor). See STABILITY.md. */ declare function mergeableSketch(options?: MergeableSketchOptions): MergeableSketch; /** * Built-in, dependency-free analytics. * * {@link withAnalytics} wraps a {@link Limiter} so every check is observed in-process — no * OpenTelemetry backend, no peer dependency, zero config. The returned object is a drop-in * {@link Limiter} (same `strategy`/`check`/`checkSync`/`reset`, delegating to the inner limiter) * augmented with two methods: * * - {@link AnalyticsLimiter.analytics} — a snapshot of the current window's traffic: allow/deny * counts and bounded-memory top-K "heavy hitters" (the keys driving the most requests and the * most denials). * - {@link AnalyticsLimiter.resetAnalytics} — clear counters and summaries. * * The window is fixed and epoch-aligned exactly like {@link fixedWindow} * (`floor(now / windowMs) * windowMs`); rolling into a new window resets counts and summaries, so a * snapshot always reflects the *current* window to date. * * Top-K uses the **Space-Saving** algorithm (Metwally, Agrawal & El Abbadi, "Efficient Computation * of Frequent and Top-k Elements in Data Streams", 2005). It tracks at most `topK` entries * regardless of how many distinct keys are observed, so memory is bounded by `topK` even under a * flood of unique keys — and it over-estimates only, never under-counts a true heavy hitter. */ /** Configuration for {@link withAnalytics}. All fields optional; sensible zero-config defaults. */ interface AnalyticsOptions { /** How many heavy hitters each summary tracks (bounds memory). Default 10. */ topK?: number; /** Fixed, epoch-aligned window width in ms. Default 60_000. */ windowMs?: number; /** Injected clock, for deterministic tests. Defaults to the system clock. */ clock?: Clock; } /** One heavy-hitter entry surfaced in a snapshot: a key and its (over-)estimated count. */ interface HeavyHitter { /** The observed key. */ key: string; /** Space-Saving frequency estimate (an upper bound on the true count). */ count: number; } /** * An immutable view of one limiter's traffic over the current window. * * `topRequested`/`topDenied` are sorted by `count` descending and never exceed the configured * `topK`. `denyRate` is `denied / total`, or `0` when `total` is `0`. */ interface AnalyticsSnapshot { /** Epoch-aligned start of the current window (`floor(now / windowMs) * windowMs`). */ windowStartedAt: number; /** The configured window width in ms (echoed for convenience). */ windowMs: number; /** Requests admitted in the current window. */ allowed: number; /** Requests denied in the current window. */ denied: number; /** `allowed + denied`. */ total: number; /** `denied / total`, or `0` when `total` is `0`. */ denyRate: number; /** Keys driving the most requests this window, count-descending. At most `topK` entries. */ topRequested: HeavyHitter[]; /** Keys driving the most denials this window, count-descending. At most `topK` entries. */ topDenied: HeavyHitter[]; } /** A drop-in {@link Limiter} that also exposes its own in-process analytics. */ interface AnalyticsLimiter extends Limiter { /** Snapshot the current window's stats. Cheap; allocates a fresh, detached object each call. */ analytics(): AnalyticsSnapshot; /** Clear all counters and both summaries (does not touch the inner limiter's state). */ resetAnalytics(): void; } /** * Wrap `limiter` so its traffic is tracked in-process. The returned {@link AnalyticsLimiter} is a * thin delegate: `strategy` and `reset` pass straight through, `check` awaits the inner async check * and records when the {@link Decision} resolves, and `checkSync` delegates to the inner sync check * (so it still throws when the inner store is async-only) and records only after it returns. * * A request is counted exactly once per *successful* inner check, against the window the check * lands in. Each observation feeds the requested summary; denials additionally feed the denied * summary. * * @experimental Excluded from the 1.x SemVer guarantee (may change in a minor). See STABILITY.md. */ declare function withAnalytics(limiter: Limiter, options?: AnalyticsOptions): AnalyticsLimiter; /** * The analytics **tap** — the lowest-level observability primitive: a callback fired once per * completed check with the decision and how long it took. Dependency-free (no OpenTelemetry, no * peer). Pipe decisions anywhere — your own metrics, structured logs, an audit stream, a custom * dashboard — without ThrottleKit prescribing the backend. * * `instrumentLimiter` (OTel) and `withAnalytics` (built-in counters) are higher-level consumers of * the same idea; reach for `tapDecisions` when you want the raw stream. */ /** Which limiter method produced a decision (so a tap can distinguish batch from single checks). */ type DecisionKind = "check" | "checkSync" | "checkMany" | "checkManySync"; /** One observed decision handed to a {@link DecisionTap}. */ interface DecisionEvent { /** The key that was checked. */ key: string; /** The effective cost of the check (default 1). */ cost: number; /** The decision returned to the caller. */ decision: Decision; /** The active strategy's stable name (e.g. `"gcra"`, `"quota"`). */ strategy: string; /** Wall time spent inside the inner check, in fractional ms (an equal share per key for batches). */ durationMs: number; /** Which limiter method produced this event. */ kind: DecisionKind; } /** A side-effecting observer of decisions. It must not throw — exceptions are swallowed. */ type DecisionTap = (event: DecisionEvent) => void; /** * Wrap `limiter` so `onDecision` fires once per completed check (after the decision resolves), then * return the decision unchanged. A throwing tap can never break the limiter — its exceptions are * caught and dropped. All limiter methods, including the optional `peek`/`forecast`/`close`, are * forwarded. * * @example * ```ts * const limiter = tapDecisions(rateLimit({ strategy: gcra({ limit: 100, periodMs: 60_000 }) }), (e) => { * if (!e.decision.allowed) log.warn({ key: e.key, retryAfterMs: e.decision.retryAfterMs }, "rate limited"); * myHistogram.observe(e.durationMs); * }); * ``` */ declare function tapDecisions(limiter: Limiter, onDecision: DecisionTap): Limiter; /** Options for {@link distributedTokenBudget}. */ interface DistributedTokenBudgetOptions { /** Token budget `L` enforced over each window, **shared across the whole fleet**. Floored to a positive integer. */ budget: number; /** Window width in ms. Windows are epoch-aligned: `floor(now/windowMs)*windowMs`. */ windowMs: number; /** * The atomic shared counter every gateway debits. Use any distributed {@link Store} (Redis, * Postgres, DynamoDB, D1, Deno KV); a {@link MemoryStore} keeps it process-local (equivalent to the * in-process {@link tokenBudget}). Built-in stores make the debit atomic, which is what bounds the * overshoot independent of fleet size. */ store: Store; /** * The counter's key in the store, so one store can back many independent budgets. All gateways * sharing a budget must use the **same** key. Default `"tokenBudget"`. */ key?: string; /** * Time source. Defaults to {@link systemClock}. On Redis the budget's window is rolled by the * *server* clock (skew-proof); on other stores it is rolled by this clock, so gateways should be * roughly NTP-synced (skew only shifts the window boundary, never the per-window total). */ clock?: Clock; } /** * A fleet-shared windowed token-budget meter — the distributed face of {@link tokenBudget}. `debit` * the *actual* tokens each stream produces; the budget is enforced across every gateway at once. */ interface DistributedTokenBudgetMeter { /** Atomically debit `tokens` (default 1, a positive integer) against the shared window. */ debit(tokens?: number): Promise; /** * Synchronous {@link DistributedTokenBudgetMeter.debit}. Only available when the configured store * supports synchronous atomic apply (e.g. {@link MemoryStore}); throws otherwise. */ debitSync(tokens?: number): Decision; /** Tokens remaining in the current shared window (`>= 0`); rolls the window but does not debit. */ remaining(): Promise; /** Forget the shared usage; the next debit starts a fresh window. */ reset(): Promise; } /** * **Distributed streaming token-budget meter** — enforce a budget of `L` tokens per window across a * *fleet* of gateways, when each request's cost is revealed only as it streams (the LLM-gateway * problem; see {@link tokenBudget} for the single-process version and the cost model). * * Each {@link DistributedTokenBudgetMeter.debit} runs one **atomic** read-modify-write against a * shared counter in `store`: it rolls the epoch-aligned window, then applies the same * *stop-at-boundary* rule as {@link tokenBudget} — admitted iff the fleet-wide `served < L` before * this debit; the single debit that crosses `L` is counted in full, then every later debit in the * window is refused. Because the check-and-increment is atomic in the store (Redis `EVAL`, Postgres * advisory lock, DynamoDB/D1/Deno KV compare-and-set), only the *one* crossing debit per window can * exceed `L`, no matter how many gateways meter concurrently: * * ```text * worst-case overshoot Δ ≤ (largest single debit) − 1 — independent of the gateway count C * ``` * * so per-token debiting (`tokens = 1`) holds the fleet to **exactly `L` tokens per window, Δ = 0**. * This is the `B = 1` instantiation of GALE window-coupled leasing with the token as the unit (see * `research/cost-uncertainty/PROPOSAL.md` and `test/cost/distributed-budget.ts`): the shared counter * resets each window, so leased-but-unspent budget cannot carry across the boundary, which is exactly * what makes the bound fleet-size-independent. * * The window is rolled inside the atomic step from a single shared key (like {@link fixedWindow}, not * a per-window key), so on Redis the *server* clock decides the window and gateway clock skew can * never split one logical window into two counters. * * @example * ```ts * import { RedisStore } from "throttlekit/redis"; * import { distributedTokenBudget } from "throttlekit"; * * // Construct the SAME budget (same key + Redis) on every gateway in the fleet. * const meter = distributedTokenBudget({ * budget: 1_000_000, windowMs: 60_000, store: new RedisStore({ client }), key: "tpm:acme", * }); * for await (const tok of completion) { * if (!(await meter.debit(1)).allowed) break; // fleet budget spent — stop generating * emit(tok); * } * ``` */ declare function distributedTokenBudget(options: DistributedTokenBudgetOptions): DistributedTokenBudgetMeter; /** * A concurrency admission shaped like the other unified-admission axes: a * {@link Decision} the algebra can combine, plus a separate `release` * tied to the caller's request lifecycle (`res.on("finish", release)`, * a `finally` block, etc.). * * The two halves are intentionally split because a {@link Decision} is * a point-in-time value but a concurrency slot has *temporal* state — it * stays held until the work completes. Combining them into one object * keeps the call-site idiomatic and surfaces the lifecycle obligation. */ interface LeaseAdmission { /** The Decision view of this acquire. Suitable as input to `combineDecisions`. */ decision: Decision; /** * Releases the concurrency slot (or is a no-op for a rejected admission). * `dropped: true` signals an overload (timeout / error) to the underlying * gradient2 / AIMD update — pass it through honestly so the limit * contracts. Idempotent: a second call is a no-op (the underlying * {@link Lease}'s release is already idempotent). */ release(opts?: { dropped?: boolean; }): void; } /** Options for {@link leaseAsAdmission}. */ interface LeaseAsAdmissionOptions { /** Injectable time source. Defaults to {@link systemClock}. */ clock?: Clock; } /** The shim surface — `acquire` mirrors the concurrency guard's primitive. */ interface LeaseAdmitter { /** Try to take a slot; the returned {@link LeaseAdmission} carries the Decision plus the release. */ acquire(): LeaseAdmission; } /** * Bridge a {@link ConcurrencyGuard}'s `acquire() → Lease` into a * Decision-shaped admission so it composes with the other unified-admission * axes via {@link combineDecisions}. The release is kept *separate* from * the Decision so the caller can wire it to the request lifecycle (see * `research/bigger-bets/unified/DESIGN.md` §5 — D-U4 and DR-08 in PLAN.md * §8: concurrency's lease semantics don't fit {@link Limiter}'s stateless * `.check() → Decision` shape, so we expose `{ decision, release }`). * * **Decision shape — accepted lease (`ok === true`):** * - `allowed: true` * - `limit: guard.limit` — the current inferred ceiling * - `remaining: max(0, guard.limit − guard.inflight)` — post-consume * (the just-acquired slot is already counted in `inflight`) * - `resetAt: clock.now()` — concurrency replenishes by *event* * (a release), not by clock, so we report "now" and let the * MAX-aggregation in `combineDecisions` pick the rate / cost axis's * real reset (always ≥ now) * - `retryAfterMs: 0` * * **Decision shape — rejected lease (`ok === false`):** * - `allowed: false` * - `limit: guard.limit` * - `remaining: 0` * - `resetAt: clock.now()` * - `retryAfterMs: max(1, round(guard.stats().lastRtt || 1))` — a * Little's-Law-honest hint, since the slot frees by event not clock. * `lastRtt` proxies the residence time `W`; under saturation the * average wait for a free slot is approximately `W`. The * `max(1, …)` floor guarantees we never tell a client "deny with * retry-immediately" (a useless signal); the `|| 1` handles the * "no samples yet" cold start. `round(…)` keeps the field integer * (the project-wide bit-identity guarantee). * * The shim is pure (no internal state); each call to `acquire` either * grabs a slot on the underlying guard or doesn't, and returns the * shaped result. Idempotency / double-release safety is inherited from * the underlying {@link Lease.release}. */ declare function leaseAsAdmission(guard: ConcurrencyGuard, options?: LeaseAsAdmissionOptions): LeaseAdmitter; /** * The admission **tap** — the multi-axis sibling of {@link tapDecisions}. Fires once per completed * unified admission with the combined decision, the binding axis, and the per-axis snapshot, so a * dashboard (the ThrottleKit Lens) can attribute every denial to the exact axis — or the joint-LP * `"policy"` lane — that bound it. * * Dependency-free. Like {@link tapDecisions}, the observer **must not throw** (exceptions are swallowed) * and runs **synchronously** right after the admit resolves, in O(1), so it can never perturb the * admission-control path. Where `tapDecisions` taps a {@link Limiter}, this taps a {@link UnifiedAdmitter}; * pair them so the universal board (any limiter) gains the binding-axis lane for unified-admission users. * * @experimental Excluded from the 1.x SemVer guarantee (may change in a minor). See STABILITY.md. */ /** A denial lane in the admission Sankey: a binding axis, or the joint-LP bid-price `"policy"` filter. */ type AdmissionLane = UnifiedAxis | "policy"; /** Which admitter method produced an event (so a tap can distinguish the async/sync paths). */ type AdmissionKind = "admit" | "admitSync"; /** One observed unified admission handed to an {@link AdmissionTap}. */ interface AdmissionEvent { /** The admit key (the rate/cost bucket); `""` is the global bucket. */ key: string; /** The cost-axis weight of the admit (default 1). */ cost: number; /** The joint-LP bid value of the admit (default 1; only meaningful under `policy: "joint-lp"`). */ value: number; /** The combined decision returned to the caller. */ decision: Decision; /** The axis that bound a denial (`undefined` on an allow, or on a joint-LP `policy` denial). */ bindingAxis?: UnifiedAxis; /** True iff a joint-LP bid-price filter denied while every per-axis budget had slack. */ policyDenied: boolean; /** * The single lane this event is attributed to in the Sankey: the binding axis, or `"policy"` for a * joint-LP denial. `undefined` when the admission was allowed. **Exactly one lane per denial.** */ lane?: AdmissionLane; /** * The per-axis decision snapshot at emit time (`= admitter.lastDecisions()`). Exact for `admitSync` * and for non-concurrent `admit`; under multiple `admit()`s racing on the *same* admitter it is * best-effort (the admitter overwrites `lastDecisions()` each admit) — the `decision`/`bindingAxis`/ * `lane` above are always exact (captured from this admit's own result), so lane attribution is exact. */ perAxis: Readonly>>; /** Wall time spent inside the inner admit, in fractional ms. */ durationMs: number; /** Which admitter method produced this event. */ kind: AdmissionKind; } /** A side-effecting observer of admissions. It must not throw — exceptions are swallowed. */ type AdmissionTap = (event: AdmissionEvent) => void; /** * Wrap `admitter` so `onAdmission` fires once per completed `admit`/`admitSync` (after the admission * resolves), then return the admission unchanged. A throwing tap can never break admission — its * exceptions are caught and dropped. `lastDecisions()` is forwarded. * * @example * ```ts * const admit = admissionTap(unifiedAdmission({ rate, concurrency, cost }), (e) => { * if (e.lane) denyCounter.add(1, { lane: e.lane }); // attribute the deny to its one binding lane * }); * ``` * * @experimental Excluded from the 1.x SemVer guarantee (may change in a minor). See STABILITY.md. */ declare function admissionTap(admitter: UnifiedAdmitter, onAdmission: AdmissionTap): UnifiedAdmitter; /** * Built-in, dependency-free **admission analytics** — the multi-axis fork of `withAnalytics`. * * {@link withAdmissionAnalytics} wraps a {@link UnifiedAdmitter} so every admit is observed in-process, * segmenting allow/deny counters AND the bounded-memory top-K heavy hitters **by binding lane** * (`rate` / `concurrency` / `cost` / `policy`). The result is a drop-in {@link UnifiedAdmitter} (same * `admit`/`admitSync`/`lastDecisions`) plus {@link AdmissionAnalyticsAdmitter.analytics} / * {@link AdmissionAnalyticsAdmitter.resetAnalytics}. * * It is a deliberate fork of `withAnalytics` (same epoch-aligned window, same Space-Saving top-K), not a * shared dependency, so this experimental surface stays isolated from the stable analytics module. The * lane of a denial is read from the admission's own `bindingAxis` (exact, never racy) — a denied admission * with no binding axis is, by the `unifiedAdmission` contract, a joint-LP `"policy"` denial — so the * counts are exact even under concurrent admits. * * @experimental Excluded from the 1.x SemVer guarantee (may change in a minor). See STABILITY.md. */ /** Configuration for {@link withAdmissionAnalytics}. Mirrors `AnalyticsOptions`. */ interface AdmissionAnalyticsOptions { /** How many heavy hitters each summary tracks (bounds memory). Default 10. */ topK?: number; /** Fixed, epoch-aligned window width in ms. Default 60_000. */ windowMs?: number; /** Injected clock, for deterministic tests. Defaults to the system clock. */ clock?: Clock; } /** One heavy-hitter entry: a key and its (over-)estimated count. Mirrors analytics `HeavyHitter`. */ interface AdmissionHeavyHitter { /** The observed key. */ key: string; /** Space-Saving frequency estimate (an upper bound on the true count). */ count: number; } /** * An immutable view of one admitter's traffic over the current window. `deniedByLane` partitions * `denied` across the four lanes — **Σ deniedByLane === denied** (exactly one lane per denial). */ interface AdmissionAnalyticsSnapshot { /** Epoch-aligned start of the current window (`floor(now / windowMs) * windowMs`). */ windowStartedAt: number; /** The configured window width in ms (echoed for convenience). */ windowMs: number; /** Admits allowed in the current window. */ allowed: number; /** Admits denied in the current window. */ denied: number; /** `allowed + denied`. */ total: number; /** `denied / total`, or `0` when `total` is `0`. */ denyRate: number; /** Denials partitioned by binding lane; every lane present, `Σ === denied`. */ deniedByLane: Record; /** Keys driving the most admits this window, count-descending. At most `topK`. */ topRequested: AdmissionHeavyHitter[]; /** Keys driving the most denials this window (any lane), count-descending. At most `topK`. */ topDenied: AdmissionHeavyHitter[]; /** Per-lane top denied keys — the Sankey's axis → top-denied-keys flow. At most `topK` per lane. */ topDeniedByLane: Record; } /** A drop-in {@link UnifiedAdmitter} that also exposes its own in-process, lane-segmented analytics. */ interface AdmissionAnalyticsAdmitter extends UnifiedAdmitter { /** Snapshot the current window's stats. Cheap; allocates a fresh, detached object each call. */ analytics(): AdmissionAnalyticsSnapshot; /** Clear all counters and summaries (does not touch the inner admitter's state). */ resetAnalytics(): void; } /** * Wrap `admitter` so its traffic is tracked in-process, segmented by binding lane. The returned * {@link AdmissionAnalyticsAdmitter} delegates `admit`/`admitSync`/`lastDecisions` to the inner admitter * and records each completed admit against the current epoch-aligned window. * * @experimental Excluded from the 1.x SemVer guarantee (may change in a minor). See STABILITY.md. */ declare function withAdmissionAnalytics(admitter: UnifiedAdmitter, options?: AdmissionAnalyticsOptions): AdmissionAnalyticsAdmitter; /** * Admission-control primitives — decide whether to *attempt* work at all, upstream of the * per-key rate limiters. Several independent tools live here: * * - {@link adaptiveThrottle}: Google-SRE client-side adaptive throttling. A client that keeps * hammering an overloaded backend only deepens the overload; this sheds a growing fraction of * requests *locally* (before they leave the client) based on the backend's recent accept rate. * - {@link fairShare}: an online equal-share approximation of max-min fairness, so one greedy * tenant cannot consume a shared global budget and starve the others. * - {@link weightedMaxMin} + {@link weightedFairShare}: weighted fairness — the exact, work-conserving * weighted max-min split of a contended budget (batch), and its online streaming limiter. * - {@link tokenBudget}: a streaming token-budget meter for *post-hoc* costs (e.g. LLM output * tokens, billed only as they stream). Debit the actual tokens as they are produced; overshoot is * bounded by the debit granularity (exactly 0 per token), independent of the per-request cap and * of how many streams meter concurrently. {@link distributedTokenBudget} is its fleet-shared, * {@link Store}-backed form, with the same bound across every gateway at once. * - {@link learnedReservation}: an online newsvendor learner for the per-request token *reservation* * that paces admission over a {@link tokenBudget} — it descends onto the cost-optimal quantile with * `O(√T)` regret, while the meter (not the reservation) holds safety unconditionally. * - {@link predictiveReservation}: learning-augmented reservation — blend a per-request output-length * *prediction* against {@link learnedReservation} with a Hedge meta-learner: accurate hints drive * cost to the clairvoyant optimum (consistency), adversarial ones fall back to the no-regret * quantile (robustness), and safety is untouched (the prediction is just a number the meter caps). * * The throttles and budgets read time only through an injected {@link Clock}, so every decision is * reproducible to the millisecond under {@link ManualClock}; the learners ({@link learnedReservation}) * carry no clock at all and are driven purely by the outcomes you feed them. All are pure JavaScript * and dependency-free. */ /** Options for {@link adaptiveThrottle}. */ interface AdaptiveThrottleOptions { /** * Acceptance multiplier `K` from the SRE formula. With `K = 2` the client only begins shedding * once it is sending more than twice what the backend accepts, tolerating a 50% rejection rate * before throttling locally; larger `K` is more permissive (sheds later). Must be `>= 1` — a `K` * below 1 would shed even a perfectly healthy backend. Default `2` (the SRE Book's value). */ k?: number; /** * Width of the rolling accounting window in ms. `requests`/`accepts` are tracked over roughly the * trailing `windowMs` so a past overload is forgotten as the backend recovers. Default `10_000`. */ windowMs?: number; /** Injected time source. Default {@link systemClock}. */ clock?: Clock; /** * Source of randomness for the probabilistic shed, returning a value in `[0, 1)`. Inject a * seeded PRNG to make shedding deterministic in tests. Default `Math.random`. */ random?: () => number; } /** * A client-side adaptive throttle. Track every request attempt with {@link AdaptiveThrottle.request} * (which tells you whether to send or shed) and feed back each sent request's outcome with * {@link AdaptiveThrottle.record}. */ interface AdaptiveThrottle { /** * Decide whether to send the next request to the backend. Returns `true` to **send**, `false` to * **shed locally** (fail fast without touching the backend). Always counts as a request attempt, * whether or not it is shed. * * `priority` in `[0, 1]` (default `0`) scales the shed probability by `(1 - priority)`: a * `priority` of `1` is never shed, `0` is shed at the full rate. Use it to protect critical * traffic (health checks, payments) while shedding the bulk. */ request(priority?: number): boolean; /** * Feed back the backend's outcome for a request that was **sent** (i.e. a {@link request} that * returned `true`). Counts an accept iff `accepted`. Locally-shed requests must NOT be recorded: * leaving them out is exactly what keeps the reject probability elevated until the backend * recovers. */ record(accepted: boolean): void; /** The current local reject probability `p` in `[0, 1]`. Read-only; does not mutate state. */ rejectProbability(): number; /** A point-in-time snapshot for metrics/introspection (rolling counts + current `p`). */ stats(): { requests: number; accepts: number; rejectProbability: number; }; } /** * Client-side adaptive throttling — Google SRE Book, Chapter 21 "Handling Overload", the * "Client-Side Throttling" section. * * The client tracks, over a recent window, `requests` (application-layer attempts) and `accepts` * (requests the backend accepted). It rejects a new request *locally*, before it ever leaves the * client, with probability: * * ```text * p = max(0, (requests - K * accepts) / (requests + 1)) * ``` * * When the backend is healthy (`accepts ≈ requests`) the numerator is negative, so `p ≈ 0` and * everything is sent. As the backend starts rejecting (`accepts → 0`) the numerator approaches * `requests`, so `p → requests/(requests+1) ≈ 1` and the client sheds nearly everything — which * relieves the backend instead of piling on. The `+1` in the denominator keeps `p` finite and * gentle when only a handful of requests have been seen. * * **Rolling-window choice (documented).** Rather than a hard reset every `windowMs` (which would * make `p` lurch — it would briefly read 0 right after a boundary even mid-overload), this keeps a * *previous* and a *current* epoch-aligned fixed window and reports a **time-weighted** blend: * `count = current + previous * (1 - elapsedFractionOfCurrentWindow)`. As the current window fills, * the previous window's contribution decays linearly to zero, so an old overload is forgotten * smoothly over roughly one `windowMs`. (This is the standard sliding-window-counter approximation, * the same shape used by {@link slidingWindow}; it weights by *time*, not by the actual arrival * positions within the previous window.) */ declare function adaptiveThrottle(options?: AdaptiveThrottleOptions): AdaptiveThrottle; /** Options for {@link fairShare}. */ interface FairShareOptions { /** Global admissions budget shared across all tenants per window. */ limit: number; /** Window width in ms. Windows are aligned to epoch: `floor(now/windowMs)*windowMs`. */ windowMs: number; /** Injected clock. Defaults to {@link systemClock}. */ clock?: Clock; } /** * A global, fixed-window budget shared fairly across tenants. The {@link Decision.limit} reported * to each tenant is *that tenant's* current fair cap, not the global budget. */ interface FairShareLimiter { /** Synchronous, zero-`await` check for `tenant` with the given `cost` (default 1). */ checkSync(tenant: string, cost?: number): Decision; /** Promise-returning form of {@link FairShareLimiter.checkSync}; resolves synchronously. */ check(tenant: string, cost?: number): Promise; /** Reset one tenant's usage (it leaves the active set), or — with no argument — the whole window. */ reset(tenant?: string): void; } /** * Equal-share fairness across tenants — an online approximation of **max-min fair allocation** * (Bertsekas & Gallager, "Data Networks", 2nd ed., §6.5.2 "max-min flow control"; the same * fairness goal as Nagle's fair queuing, RFC 970, "On Packet Switches with Infinite Storage"). * * One global budget of `limit` admissions per epoch-aligned window is shared so that no single * tenant can monopolize it. Within a window we track the global total admitted, each tenant's * admitted amount, and the set of tenants that have been **active** (made at least one check) this * window. A tenant joins the active set on its first check. The per-tenant ceiling is * * ```text * fairCap = max(1, floor(limit / activeCount)) * ``` * * and a request is admitted iff `total + cost <= limit` **and** `tenantUsed + cost <= fairCap`. * * **Honest limitations (read these — do not over-rely on the word "fair").** This is an *online * equal-share approximation*, not exact, work-conserving max-min fairness: * * 1. **No starvation, hard global cap (what it *does* guarantee).** Every active tenant may admit at * least `floor(limit / N)` (where `N` is the active-tenant count at the time), and the global * total admitted in a window never exceeds `limit`. So a greedy tenant cannot starve the others, * and the budget is never overspent. * 2. **Caps shrink mid-window.** `fairCap` is recomputed from the *current* `activeCount`, which * only ever grows within a window. A tenant that grabbed its full share early, before others * appeared, keeps what it already took even though everyone's cap has since dropped — so the * realized split can be less even than `limit / N` for that window. (It self-corrects next * window, which starts fresh.) * 3. **Not work-conserving; spare capacity is first-come.** Capacity left unused by idle tenants is * handed out on a first-come basis up to the global `limit`, **not** perfectly redistributed to * the remaining tenants the way true max-min fairness would. An active tenant can use idle * tenants' slack only until those tenants show up or the global budget runs out. * 4. **Per-window memory is O(distinct tenants).** The active-tenant map is cleared only when the * window rolls, so it grows with the number of distinct tenant keys seen within a window. Key it * on a **bounded, trusted** tenant set (not raw client input); for an unbounded/untrusted key * universe, front it with {@link sketchRateLimit}. (Eviction is intentionally not offered here: * dropping a tenant mid-window would change the fair-share divisor and skew the split.) * * In short: a robust anti-starvation budget splitter with a hard global ceiling — not a precise * max-min fair scheduler. */ declare function fairShare(options: FairShareOptions): FairShareLimiter; /** * Each tenant's **guaranteed weighted share** `floor(w_i · limit / W)` (`W` = total weight) — the * static slice a weighted max-min split never drops a backlogged tenant below. Sums to `<= limit`. * * Integer-first form (`w · limit / W` then floor) to avoid the float-precision trap of * `(w / W) · limit` — e.g. `(6/11) * 99 = 53.999...` floors to 53 instead of 54. */ declare function guaranteedShare(weights: readonly number[], limit: number): number[]; /** * **Weighted max-min fair allocation** of an integer `limit` across tenants with per-tenant `demands` * and positive `weights` — the heart of *Weighted Fair Escrow*. Returns the integer credits each * tenant receives: * * - **work-conserving** — sums to exactly `min(Σ demand, floor(limit))`; a tenant demanding below its * share never strands the remainder, it flows to the backlogged tenants; * - **weight-honoring** — every backlogged tenant gets at least its guaranteed share * `floor(w_i/W·limit)`, and surplus is split so all backlogged tenants reach a common *weighted* * service level `a_i / w_i` (perfectly fair up to the ≤ 1-credit integer rounding gap). * * Equal weights reduce it to ordinary (unweighted) max-min. Computed as continuous water-filling * (`O(n log n)`) plus a bounded integer drip of the `< n` rounding remainder, so it is fast even for a * large `limit`. Pure. This is the batch primitive; for streaming admission see {@link weightedFairShare}. */ declare function weightedMaxMin(demands: readonly number[], weights: readonly number[], limit: number): number[]; /** Options for {@link weightedFairShare}. */ interface WeightedFairShareOptions { /** Global admissions budget shared across all tenants per window. */ limit: number; /** Window width in ms. Windows are aligned to epoch: `floor(now/windowMs)*windowMs`. */ windowMs: number; /** Per-tenant weight (a tenant's share is proportional to it). Default `() => 1` (equal — i.e. fairShare). */ weightOf?: (tenant: string) => number; /** Injected clock. Defaults to {@link systemClock}. */ clock?: Clock; } /** * A global, fixed-window budget shared across tenants **in proportion to weight**. The * {@link Decision.limit} reported to each tenant is *that tenant's* current weighted fair cap. */ interface WeightedFairShareLimiter { /** Synchronous check for `tenant` with `cost` (default 1) and optional per-check `weight` override. */ checkSync(tenant: string, cost?: number, weight?: number): Decision; /** Promise-returning form of {@link WeightedFairShareLimiter.checkSync}; resolves synchronously. */ check(tenant: string, cost?: number, weight?: number): Promise; /** Reset one tenant's usage (it leaves the active set), or — with no argument — the whole window. */ reset(tenant?: string): void; } /** * **Weighted** equal-share fairness across tenants — the weighted generalization of {@link fairShare} * (and the streaming face of {@link weightedMaxMin}). One global budget of `limit` admissions per * epoch-aligned window is split so each active tenant's ceiling is proportional to its weight: * * ```text * fairCap_i = max(1, floor(weight_i / W * limit)) // W = total weight of active tenants * ``` * * and a request is admitted iff `total + cost <= limit` **and** `tenantUsed + cost <= fairCap_i`. A * weight-4 tenant thus gets ~4× the share of a weight-1 tenant, and no tenant can be starved below its * weighted floor by a flood from the others. * * **Honest limitations (identical in spirit to {@link fairShare} — read them).** This is an *online * weighted equal-share approximation*, not exact work-conserving weighted max-min: * * 1. **Weighted floor + hard global cap (the guarantee).** Every active tenant may admit at least its * weighted floor `floor(w_i/W·limit)` (`W` = active weight at the time), and the window total never * exceeds `limit`. * 2. **Caps shrink as tenants arrive.** `W` only grows within a window, so an early tenant that took * its full share keeps it even after later arrivals lower everyone's cap (self-corrects next window). * 3. **Surplus is first-come, not redistributed.** Capacity left idle by light tenants is handed out * first-come up to `limit`, not perfectly reallocated by weight the way true max-min would. When you * have all tenants' demands at once and want the exact, fully work-conserving weighted split, call * {@link weightedMaxMin} instead (e.g. to divide a node's leased batch among its local tenants). * 4. **Per-window memory is O(distinct tenants)** (same as {@link fairShare}): key it on a bounded, * trusted tenant set, or front an untrusted key universe with {@link sketchRateLimit}. */ declare function weightedFairShare(options: WeightedFairShareOptions): WeightedFairShareLimiter; /** Options for {@link tokenBudget}. */ interface TokenBudgetOptions { /** Token budget `L` enforced over each window. Floored to an integer; must be positive. */ budget: number; /** Window width in ms. Windows are epoch-aligned: `floor(now/windowMs)*windowMs`. */ windowMs: number; /** Injected clock. Defaults to {@link systemClock}. */ clock?: Clock; } /** * A windowed token-budget meter — the streaming face of post-hoc cost control. Debit the *actual* * tokens a stream produces as they are produced; see {@link tokenBudget}. */ interface TokenBudgetMeter { /** * Atomically debit `tokens` (default 1, a positive integer) against the current window. * * **Stop-at-boundary / partial-admit:** a debit is admitted iff budget remains *before* it * (`served < L`), counting post-hoc cost honestly — so the debit that crosses `L` is admitted in * full and only the *next* debit is refused. Debit per token (`tokens = 1`) for zero overshoot; a * larger chunk can carry `served` past `L` by up to `tokens − 1`. To reject a chunk that would * exceed the budget instead, gate on {@link TokenBudgetMeter.remaining} first. See {@link tokenBudget}. */ debitSync(tokens?: number): Decision; /** Promise-returning form of {@link TokenBudgetMeter.debitSync}; resolves synchronously. */ debit(tokens?: number): Promise; /** Tokens remaining in the current window (`>= 0`); rolls the window but does not debit. */ remaining(): number; /** Forget all usage; the next call starts a fresh window. */ reset(): void; } /** * **Streaming token-budget meter** — enforce a budget of `L` tokens per window when each request's * cost is revealed only *as it streams*. This is the LLM-gateway problem: you do not know how many * output tokens a completion will use until it has produced them, so you cannot price it at * admission. * * Call {@link TokenBudgetMeter.debit} for each chunk a stream produces (ideally one token at a * time). A debit is **admitted iff budget remains before it** (`served < L`); the single debit that * crosses `L` is still counted in full, then every later debit in the window is refused * (`allowed: false`) so the caller stops generating. This *stop-at-boundary* rule bounds the * overshoot by the debit granularity: * * ```text * worst-case overshoot Δ ≤ (largest single debit) − 1 * ``` * * so **per-token debiting (`tokens = 1`) overshoots by exactly 0** — the meter stops on the token * that reaches `L`. Two properties make this strong: * * - **Independent of the per-request cap (`max_tokens`).** The meter never reserves a request's * cap; it counts only what is actually produced. A heavy-tailed length distribution costs it * nothing, so utilization stays ~1 with no tail waste. * - **Independent of concurrency.** Because each debit's check and increment are a single * synchronous step, only the one crossing debit can exceed `L`, no matter how many streams meter * through the instance at once. * * It thus dominates the two production corners on both axes at once: * * - **reserve `max_tokens` up front** (e.g. an API gateway that estimates the cap at admission and * reconciles later): never overshoots, but sterilizes most of every reservation on a heavy tail — * utilization collapses as the cap grows. * - **admit-then-count** (charge the real cost only at completion): fully utilized, but the streams * in flight when the budget runs out overshoot by up to `C · max_tokens` (`C` = concurrency). * * The meter gives reserve-max's safety (`Δ = 0` per token) at admit-then-count's utilization (`~1`), * with no dependence on the cap. * * **Single-instance / single-gateway.** The synchronous check-then-increment is atomic only within * one process. To share a budget across a fleet of gateways, back it with an atomic shared counter: * that is GALE window-coupled leasing with the token as the unit (see * `research/cost-uncertainty/PROPOSAL.md`), so the distributed token meter inherits the leased * budget's fleet-size-independent overshoot bound. * * Not to be confused with {@link tokenBucket}, a *rate* limiter that refills capacity over time: * `tokenBudget` enforces a *fixed quota* of post-hoc-metered cost over a fixed window. * * @example * const meter = tokenBudget({ budget: 100_000, windowMs: 60_000 }); * for await (const tok of completion) { * if (!meter.debitSync(1).allowed) break; // budget spent — stop generating * emit(tok); * } */ declare function tokenBudget(options: TokenBudgetOptions): TokenBudgetMeter; /** Options for {@link learnedReservation}. */ interface LearnedReservationOptions { /** Hold cost `h`: penalty per token *reserved but unused* — the cost of a needless reject. Must be `> 0`. */ holdCost: number; /** Overrun cost `p`: penalty per token of realised cost *beyond* the reservation — the cost of an abort. Must be `> 0`. */ overrunCost: number; /** Upper clamp on the reservation = the per-request cap `m`; also the reservation-domain diameter. Must be `> 0`. */ maxReservation: number; /** Lower clamp on the reservation. Default `0` (no admission gate — admit into any free slot). */ minReservation?: number; /** Initial reservation. Default the feasible midpoint `(minR+maxR)/2`, a neutral prior. */ initialReservation?: number; /** OGD step scale `η₀` in the step `η₀/√t`. Default `D/G = (maxR−minR)/max(h,p)`, the Zinkevich-optimal scale. */ stepScale?: number; } /** A learned per-request reservation: commit a reservation, then learn from each realised cost. */ interface LearnedReservation { /** The integer reservation to commit for the next request, in `[minReservation, maxReservation]`. */ reserve(): number; /** Feed the realised cost once a request completes; updates the reservation for subsequent requests. */ observe(cost: number): void; /** The continuous internal reservation (before rounding/clamping), for introspection. */ readonly continuous: number; } /** * The **critical-fractile** quantile level `τ = p/(h+p)` — the cost quantile that minimises the * asymmetric newsvendor / pinball loss, and the target {@link learnedReservation} descends onto. With * `h = p` it is the median (`0.5`); a costlier overrun (`p > h`) pushes it toward higher percentiles. */ declare function criticalFractile(holdCost: number, overrunCost: number): number; /** * **Online learned reservation** (TALE Layer 2) — learn the per-request token *reservation* `r` that * best paces admission over a {@link tokenBudget}, when each request's true cost (its output tokens) * is revealed only *after* it runs. * * A {@link tokenBudget} bounds *overshoot* for any reservation, but admission still needs a reservation * committed *before* the cost is known — it sets the 429 and paces concurrency. Reserve too much * (`r = max_tokens`) and you reject admissible traffic and starve concurrency; reserve too little and * you over-admit, so the meter has to abort in-flight streams at the boundary (wasted half-finished * work). The per-request regret of a reservation `r` against the realised cost `c` is the asymmetric * **newsvendor / pinball** loss * * ```text * ℓ(r, c) = holdCost·(r − c)₊ + overrunCost·(c − r)₊ * ``` * * whose population minimiser is the {@link criticalFractile} quantile `τ = overrunCost/(holdCost+overrunCost)` * of the cost distribution. This learns it online with **projected online gradient descent** (Zinkevich, * ICML'03): {@link LearnedReservation.reserve} commits the current reservation, and * {@link LearnedReservation.observe} feeds back the realised cost (full information — the cost is known * once the stream finishes), stepping the reservation by the pinball subgradient (`+h` when it * over-reserved, `−p` when it under-reserved). With the canonical `η_t = η₀/√t` step this attains * **`O(√T)` regret** versus the best fixed reservation in hindsight (`R_T ≤ (3/2)·D·G·√T`, with * `D = maxR−minR`, `G = max(h,p)`; see `research/cost-uncertainty/REGRET-ANALYSIS.md`). * * **Safety is not this learner's job.** The reservation only governs the false-reject ⇆ abort * trade-off; the {@link tokenBudget} meter caps production at the budget for *any* reservation * whatsoever, so no choice of `r` — learned, maximal, or zero — can breach the budget `L`. Pair the * two: the meter holds the hard bound, this learner makes admission efficient. * * Pure and deterministic — no clock, no RNG; driven entirely by the costs you * {@link LearnedReservation.observe}. For predictions-with-safety (a per-request length hint blended * against this robust learner), see {@link predictiveReservation}. * * @example * const meter = tokenBudget({ budget: 100_000, windowMs: 60_000 }); * const policy = learnedReservation({ holdCost: 1, overrunCost: 4, maxReservation: 4096 }); * // at admission, only let a request in if its reservation fits the remaining budget: * if (policy.reserve() <= meter.remaining()) { * let produced = 0; * for await (const tok of completion) { * if (!meter.debitSync(1).allowed) break; // budget spent — stop generating * produced++; * emit(tok); * } * policy.observe(produced); // learn from the realised cost * } * * @experimental Excluded from the 1.x SemVer guarantee (may change in a minor). See STABILITY.md. */ declare function learnedReservation(options: LearnedReservationOptions): LearnedReservation; /** Options for {@link predictiveReservation}. */ interface PredictiveReservationOptions extends LearnedReservationOptions { /** Hedge learning rate `η` (expert weights ∝ `exp(−η · cumulative expert loss)`). Default `0.01`. */ learningRate?: number; } /** A predictions-with-safety reservation: blend a per-request length hint against the robust learner. */ interface PredictiveReservation { /** Commit a reservation for the next request, given its predicted output length. */ reserve(prediction: number): number; /** Learn from the realised cost: update both experts' weights and the robust learner. */ observe(cost: number): void; /** Current expert weights `[followPrediction, robust]` (sum to 1), for introspection. */ readonly weights: readonly [number, number]; } /** * **Learning-augmented reservation** (TALE Layer 3) — like {@link learnedReservation}, but able to * exploit a *per-request* output-length prediction when one is available, without trusting it. * * Predicting an LLM completion's exact length is infeasible, but its relative *rank* is learnable * (Fu et al., "Efficient LLM Scheduling by Learning to Rank", NeurIPS'24). This runs two experts each * request — "follow the prediction" and the robust {@link learnedReservation} quantile learner — and a * **Hedge** meta-learner sets convex weights from each expert's realised pinball loss; it plays the * weighted-average reservation. Because the pinball loss is convex, Jensen gives * `loss(blend) ≤ weighted-average expert loss`, and Hedge drives weight onto the better expert: * * - **accurate predictions ⇒ weight → follow ⇒ cost → the clairvoyant optimum** (consistency); * - **adversarial predictions ⇒ weight → robust ⇒ cost → the no-regret quantile** (robustness). * * **Safety is untouched.** The reservation is just a number the {@link tokenBudget} meter overrides at * the budget boundary, so *no* prediction — however adversarial — can breach the budget. This is the * predictions-with-safety guarantee on the cost axis: speed up the common case, never trade away the * hard bound. * * Pure and deterministic — no clock, no RNG. You supply the prediction; if you have none, pass `0` * (or use {@link learnedReservation} directly). Design + proofs: `research/cost-uncertainty/`. * * @example * const policy = predictiveReservation({ holdCost: 1, overrunCost: 4, maxReservation: 4096 }); * const r = policy.reserve(predictedOutputLength); // blends the hint with the robust learner * // …run the request under a tokenBudget meter, then: * policy.observe(producedTokens); * * @experimental Excluded from the 1.x SemVer guarantee (may change in a minor). See STABILITY.md. */ declare function predictiveReservation(options: PredictiveReservationOptions): PredictiveReservation; export { ALLOW_FULL, AdaptiveConcurrencyOptions, type AdaptiveThrottle, type AdaptiveThrottleOptions, type AdmissionAnalyticsAdmitter, type AdmissionAnalyticsOptions, type AdmissionAnalyticsSnapshot, type AdmissionEvent, type AdmissionHeavyHitter, type AdmissionKind, type AdmissionLane, type AdmissionTap, type AnalyticsLimiter, type AnalyticsOptions, type AnalyticsSnapshot, Clock, type ConcurrencyCoordinator, type ConcurrencyGrant, ConcurrencyGuard, type ConcurrencyReport, Decision, type DecisionEvent, type DecisionKind, type DecisionTap, type Dimension, type Dimensions, type DistributedAdaptiveConcurrencyOptions, type DistributedConcurrencyGuard, type DistributedTokenBudgetMeter, type DistributedTokenBudgetOptions, type FairShareLimiter, type FairShareOptions, type FixedWindowOptions, type GcraOptions, type HeartbeatScheduler, type HeavyHitter, type LeakyBucketOptions, type LearnedReservation, type LearnedReservationOptions, type LeaseAdmission, type LeaseAdmitter, type LeaseAsAdmissionOptions, Limiter, MemoryStore, type MemoryStoreOptions, type MergeableSketch, type MergeableSketchOptions, type MultiLimiter, type MultiRateLimitOptions, type MultiStrategy, PostgresConcurrencyCoordinator, type PostgresConcurrencyCoordinatorOptions, type PredictiveReservation, type PredictiveReservationOptions, QueueFullError, type RateLimitOptions, RedisConcurrencyCoordinator, type RedisConcurrencyCoordinatorOptions, type Reservation, type Shaper, type SketchRateLimitOptions, type SketchRateLimiter, type SketchSnapshot, type SlidingWindowLogOptions, type SlidingWindowOptions, Store, Strategy, TestConcurrencyCoordinator, ThrottleKitError, type TokenBucketOptions, type TokenBudgetMeter, type TokenBudgetOptions, Transform, UnifiedAdmitter, UnifiedAxis, type WeightedFairShareLimiter, type WeightedFairShareOptions, adaptiveThrottle, admissionTap, all, any, combineDecisions, criticalFractile, distributedAdaptiveConcurrency, distributedTokenBudget, fairShare, fixedWindow, gcra, guaranteedShare, hashKey, hmacKeyer, leakyBucket, learnedReservation, leaseAsAdmission, mergeableSketch, multiRateLimit, predictiveReservation, rateLimit, sketchRateLimit, sketchSnapshotFromBytes, slidingWindow, slidingWindowLog, tapDecisions, tokenBucket, tokenBudget, version, weightedFairShare, weightedMaxMin, withAdmissionAnalytics, withAnalytics };