throttlekit
·

A performance sweep, then a hardening pass: 1.6.1 to 1.7.0

Two back-to-back releases across all three packages: a measured performance sweep (throttlekit 1.6.1, server 0.4.4, py 0.5.1), followed one day later by a fuzz-and-mutation hardening pass that closed a non-finite-decision bug class (throttlekit 1.7.0).

Two releases landed back to back. On June 30th, a measured performance sweep across all three packages. On July 1st, a hardening pass on the core that closed a class of boundary bugs a new fuzz harness turned up. Neither touches the frozen 1.x API or the wire. Here’s what shipped in each.

throttlekit 1.6.1 — the performance sweep

A multi-agent audit ran across the core, the server, and the Python client: finders proposed optimizations by dimension (allocation, hashing, round trips, and so on), then every finding went through an adversarial pass for hotness, impact, and safety before it was allowed to land. 33 opportunities came out of that, 29 confirmed and 4 dismissed as real but negligible (the kind of inefficiency that only shows up on a cold path or over the network). The already-hot gcra checkSync path (~170 ns) got zero findings, which is the right outcome: it was already allocation-tight.

Most of the audit’s real targets turned out to sit on paths with no benchmark at all yet: the multi-dimension combine, the two-tier weighted-fair-escrow grant, the server’s Check handler. So the first step wasn’t optimizing, it was writing benchmarks for those paths and wiring them into the CI regression gate. That’s what made the wins provable, and what keeps them from regressing later. Same machine, median of three runs, before vs. after:

PathBeforeAfterΔ
multiRateLimit.checkSync (2-dim)4968 ns2968 ns−40%
multiRateLimit.checkSync (3-dim)7507 ns4599 ns−39%
weightedFairEscrow grant (8 tenants)182 ns155 ns−15%
server RateLimiter.Check handler898 ns (1.11M/s)675 ns (1.48M/s)−25%

The core single-strategy checkSync paths (gcra, tokenBucket, fixedWindow) are flat within noise on purpose: they were already gated and tight, so nothing there needed touching.

The hero fix was almost embarrassing once found. The multi-dimension sync path was doing a deep structuredClone of every dimension’s stored state on every decision, to protect the no-partial-consume contract, even when that state was an immutable primitive (which is exactly GCRA’s case). Gating the clone on object-ness, so primitives skip it and only object/array states still clone, cut about 40% off the common per-IP-and-per-user composite case with byte-identical decisions. Three independent finders converged on the same fix.

The one with a real trap was the weighted-fair-escrow aggregate. Swapping an O(active-tenants) rescan for a running Σweight/Σused counter is only safe for integer weights and costs: a mutation-order float sum can 1-ULP-diverge from a fresh rescan and flip a floor decision. So the fast path only fires on integer input and falls back to the exact rescan the instant a fractional value shows up. A new conformance test drives fractional adversarial timelines and also proves the integer gate itself is load-bearing, meaning the test fails if someone quietly removes it.

Everything here is an internal optimization. Decisions, the frozen 1.x API, and the golden wire vectors are all unchanged. A few candidate optimizations were declined too: some were high-risk for low reward, others were blocked by the frozen, checksummed wire scripts. Optimize as much as possible, but not at the cost of the guarantees.

throttlekit-server 0.4.4

The server’s share of the sweep is the Check handler line above (898 ns → 675 ns, −25%, 1.11M → 1.48M ops/s), part of the same measured pass.

throttlekit-py 0.5.1

The Python client picked up its own slice of the sweep: slots=True on the per-call frozen dataclasses (Decision, Forecast, Lease, LeaseGrant) roughly halves a transported decision’s footprint, from 160 to 72 bytes. The Redis backend now precomputes its ARGV template once per backend instead of rebuilding it on every call. A handful of smaller inlining cleanups round it out.

In total, 18 optimizations landed across the three repos, each as a small, byte-identical, individually tested commit.

throttlekit 1.7.0 — the hardening pass (core only)

After the perf sweep, the next question was how to make the core fail loud instead of fail weird. A new bug-surfacing layer went in: Stryker mutation testing on the core decision math (a survivor campaign lifted the score from 65.4% to 77.5%, behind a regression floor), model-based fc.commands suites over composite and sequencing behavior, a live-store CI gate (Redis, Postgres, and DynamoDB) running on every push, and fast-check fuzz harnesses on the untrusted-input boundaries: the config parser, IP/XFF parsing, key/cost handling, and sketch merge. Mutation testing and model-based testing found no product bugs (the earlier audit’s fixes held). The fuzzers found five real issues, F1 through F5, all now guarded.

The finding that generalized the furthest: a subnormal gcra limit made an internal derived time value (period / limit) evaluate to Infinity, which produces a non-finite resetAt / retryAfterMs, which becomes a malformed RateLimit-Reset HTTP header. Guarding only gcra would have been a trap, because the same shape (a construction parameter overflowing derived time arithmetic) exists in every sibling strategy that divides by a rate or a period. So instead of a one-off patch, a single executable invariant test drives each strategy through adversarial construction parameters and asserts it either throws a RangeError or only ever emits finite decisions. That turned the fix into a sweep, and it caught two more cases along the way: a finite but astronomical emission interval could still overflow the accumulating internal state after just two requests, so the real bound has to be a safe-integer count of milliseconds, not merely “finite.” And the worst case was leakyBucket, where a non-finite computed delay made its schedule() call sleep(Infinity) and simply never fire: a silent hang, not a bad number.

The rest of the boundary hardening:

  • The zero-dependency YAML/JSON config parser now caps block-nesting depth at 64. A deeply nested untrusted .throttlekit.yaml could previously stack-overflow the process, a denial of service on untrusted config text.
  • A per-request cost above Number.MAX_SAFE_INTEGER is now rejected. It previously overflowed internal cost arithmetic to a non-finite retry value.
  • slidingWindow.buckets is now capped at 100,000. An unbounded value was a memory amplification vector, since the estimator holds an O(buckets) ring per key.
  • A decoded DDoS-sketch snapshot with a poisoned or non-finite total count is now rejected on decode and ignored on merge, protecting the cluster-wide count used for shed decisions from a malformed peer snapshot.

This is a minor release, not a patch, for one specific reason: a handful of previously “working” (silently garbage-returning) absurd option values now throw a RangeError instead. No valid input’s behavior changed, but that’s still a real behavior change worth flagging.

Status: the storeless core test suite is at 1551 tests green, lint and strict types are clean, and package surface checks (publint, attw) are clean.

Full per-release detail lives in each package’s CHANGELOG.md, linked from the changelog; current shipped versions are on the status board. None of this touches the frozen 1.x API or the gRPC wire.