Skip to content

feat(ratelimit): add admission limiter primitives - #44

Merged
OnlineChef merged 8 commits into
devfrom
feat/admission-rate-limit
Aug 2, 2026
Merged

feat(ratelimit): add admission limiter primitives#44
OnlineChef merged 8 commits into
devfrom
feat/admission-rate-limit

Conversation

@OnlineChef

@OnlineChef OnlineChef commented Aug 2, 2026

Copy link
Copy Markdown

Summary

Implements the pure admission-control primitives from lane 3 of structure/plugin-metrics-ratelimit-benchmarks.md without activating rate limiting in any runtime route.

  • HMAC-SHA-256 process-local principal fingerprints with domain separation for admission keys, management principals, remote addresses, and the shared anonymous class.
  • Opaque branded principal type prevents callers from passing raw credentials or arbitrary identity strings into limiter APIs.
  • Synchronous token-bucket consumption with monotonic time, integer burst support, cost validation, stale eviction, a hard principal-bucket cap, and bounded per-surface overflow buckets.
  • Separate WebSocket concurrency reservations with per-principal and global caps, fail-closed principal tracking capacity, idempotent release handles, and generation isolation so stale handles cannot corrupt state after reset.
  • Aggregate-only allow/deny statistics; no raw principals, models, prompts, accounts, origins, or credentials.

Explicit boundaries

  • No endpoint imports these primitives yet; existing runtime behavior is unchanged.
  • No Origin bypass exists in the API.
  • No config schema, 429 envelope, principal extraction from auth, metrics projection, or WebSocket handshake wiring is included.
  • Runtime integration will land as a separate PR after the metrics lane, reducing security review and rollback scope.

Tests

tests/ratelimit.test.ts covers:

  • keyed/domain-separated/non-reversible fingerprints;
  • weak-secret and oversized-principal rejection;
  • burst, denial, integer retry metadata, refill, principal/surface isolation;
  • backward clock movement;
  • stale eviction and bounded overflow behavior;
  • policy/cost/cap validation;
  • per-principal/global WebSocket caps, tracking-cap failure, idempotent release, and reset-generation isolation.

Validation

  • root TypeScript typecheck on Linux, macOS, and Windows
  • focused limiter tests
  • full repository tests on Linux, macOS, and Windows
  • privacy scan
  • GUI lint/build and global package smokes
  • React Doctor

This PR is intentionally inert until the follow-up integration lane imports it.

Greptile Summary

This change introduces process-local principal fingerprinting, token-bucket admission control, and WebSocket concurrency reservation primitives with focused tests. Runtime testing found that changing a token-bucket policy for an existing bucket reinterprets elapsed time using the new rate and burst: a low-to-high change admits requests that should remain limited, while a high-to-low change rejects a request that should be available. Update the token-bucket state transition before merging.

Confidence Score: 4/5

Not safe to merge until token-bucket policy changes preserve the governing policy for elapsed bucket state.

A controlled-clock execution reproduced both directions of the policy-transition failure against the real limiter: raising a policy after depletion over-admits requests, while lowering it can deny a request that should be admitted.

Files Needing Attention: src/ratelimit/token-bucket.ts needs an explicit policy-change transition and regression coverage in tests/ratelimit.test.ts.

T-Rex T-Rex Logs

What T-Rex did

  • T-Rex produced a finding-comment-proof for a posted P1 finding and attached three artifacts showing the Bun reproduction source and baseline and post-change limiter decisions.
  • T-Rex produced a second finding-comment-proof for a posted P1 finding with no artifacts attached.
  • T-Rex validated contract behavior by showing that baseline with a low policy denies at 10 seconds and a high policy allows at 1 second, and that after the change, low→high admitted 10/11 at 10 seconds while high→low denied at 1 second.

View all artifacts

T-Rex Ran code and verified through T-Rex

Comments Outside Diff (1)

  1. General comment

    P1 Latest policy retroactively refills existing token bucket state

    • Bug
      • An existing principal bucket records only token balance and timestamps. At the next consume, line 156 refills the entire elapsed interval using that call's policy. Runtime evidence shows a bucket exhausted under { requestsPerMinute: 1, burst: 1 } at t=0 admits 10 requests at t=10s when the later call supplies { requestsPerMinute: 600, burst: 10 }; unchanged low policy instead denies. Conversely, a bucket exhausted under the high policy at t=0 is denied at t=1s when the later call supplies the low policy, while unchanged high policy allows.
    • Cause
      • BucketState does not retain the policy that governed the elapsed interval, and refill(state, policy, now) receives the latest policy input. Its calculation at lines 208–213 therefore applies the latest rate and burst cap retroactively.
    • Fix
      • Associate a policy/version with bucket state and define an explicit policy-change transition. Refill elapsed time using the bucket's prior policy before replacing it; then clamp or otherwise migrate balance according to the intended new-policy semantics. Add regression tests for both low→high and high→low transitions.

    T-Rex Ran code and verified through T-Rex

Fix All in Cursor Fix All in Codex Fix All in Claude Code Fix All in Conductor

Prompt To Fix All With AI
### Issue 1
src/ratelimit/token-bucket.ts:156
**Latest policy retroactively refills existing token bucket state**

An existing bucket retains only its balance and timestamps, so this call refills all elapsed time using whichever policy the caller supplies now. A controlled-clock reproduction exhausted a `{ requestsPerMinute: 1, burst: 1 }` bucket at t=0, then passed `{ requestsPerMinute: 600, burst: 10 }` at t=10s: it admitted 10 of 11 requests, whereas retaining the low policy denies. The reverse transition also falsely denies: a bucket exhausted under the high policy is denied at t=1s when the later call passes the low policy, although the unchanged high policy allows it. Persist the prior policy, or a policy version, with each bucket; refill elapsed time under that prior policy before applying an explicit balance migration to the new one.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(ratelimit): invalidate release handl..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Context used:

  • Context used - Focking gretig zijn en niet stoppen tot perfectie.... (source)

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@OnlineChef, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 51 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f4b0f2c5-c347-411a-9508-1af36446d5a9

📥 Commits

Reviewing files that changed from the base of the PR and between 77a3dcf and 2527dc5.

📒 Files selected for processing (5)
  • src/ratelimit/concurrency.ts
  • src/ratelimit/index.ts
  • src/ratelimit/principal.ts
  • src/ratelimit/token-bucket.ts
  • tests/ratelimit.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the enhancement New feature or request label Aug 2, 2026

Copy link
Copy Markdown
Author

CodeRabbit (@coderabbitai) review

Please review the pure admission-control core on the current draft head, especially opaque principal construction, HMAC domain separation, hard-cap overflow behavior, retry/reset arithmetic, stale eviction, clock rollback, and idempotent WebSocket release. Endpoint integration is intentionally not present yet.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Changed files ---'
git diff --name-status HEAD^ HEAD

printf '%s\n' '--- Diff summary ---'
git diff --stat HEAD^ HEAD

printf '%s\n' '--- Rate-limit related paths ---'
fd -i 'ratelimit|rate-limit|rate_limit|limiter|admission' . -t f | sort

printf '%s\n' '--- TypeScript outlines in changed files ---'
while IFS= read -r file; do
  case "$file" in
    *.ts|*.tsx)
      printf '\n### %s\n' "$file"
      ast-grep outline "$file" --items all --view expanded || true
      ;;
  esac
done < <(git diff --name-only HEAD^ HEAD)

Length of output: 1356


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- src/ratelimit/token-bucket.ts outline ---'
ast-grep outline src/ratelimit/token-bucket.ts --items all --view expanded || true

printf '%s\n' '--- src/ratelimit/token-bucket.ts patch ---'
git diff --no-ext-diff --unified=80 HEAD^ HEAD -- src/ratelimit/token-bucket.ts

printf '%s\n' '--- Current implementation ---'
cat -n src/ratelimit/token-bucket.ts

for file in \
  tests/ratelimit.test.ts \
  tests/forward-admission-separation.test.ts \
  tests/rate-limit-reset-credits.test.ts
do
  if [ -f "$file" ]; then
    printf '\n--- %s ---\n' "$file"
    cat -n "$file"
  fi
done

Length of output: 56796


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- src/ratelimit files ---'
fd . src/ratelimit -t f | sort

printf '%s\n' '--- src/ratelimit source outlines ---'
for file in $(fd -e ts . src/ratelimit -t f | sort); do
  printf '\n### %s\n' "$file"
  ast-grep outline "$file" --items all --view expanded || true
done

printf '%s\n' '--- PR-base diff for src/ratelimit ---'
git diff --no-ext-diff --unified=40 HEAD^ HEAD -- src/ratelimit

printf '%s\n' '--- src/ratelimit source ---'
for file in $(fd -e ts . src/ratelimit -t f | sort); do
  printf '\n### %s\n' "$file"
  cat -n "$file"
done

printf '%s\n' '--- Typed limiter call sites ---'
rg -n -P --glob '*.ts' --glob '*.tsx' -C 4 '\.(consume|reserve)\s*\(' src tests || true

Length of output: 42525


@OnlineChef I found one correctness issue in the WebSocket reservation lifecycle.

High — stale release handles can remove post-reset reservations.
In src/ratelimit/concurrency.ts, Line 117 creates a release closure that mutates the current counts map and globalCount. Line 140 reset() clears that state, but it does not invalidate existing release closures.

For example:

  1. Reserve principal A.
  2. Call reset().
  3. Reserve principal B.
  4. Call the old release handle for principal A.

The old handle decrements globalCount at Line 123 even though it does not own B’s reservation. If A is reserved again after the reset, Line 120–122 can also delete or decrement A’s new count. The limiter then undercounts active connections and can admit connections over the configured cap.

Capture a generation value in each accepted reservation. Increment the generation in reset(). Make a release handle a no-op when its captured generation differs from the current generation.

private generation = 0;

reset(): void {
  this.generation += 1;
  this.counts.clear();
  this.globalCount = 0;
  // reset stats
}

reserve(/* ... */): ConcurrencyReservation {
  // validate and admit
  const generation = this.generation;
  let released = false;

  return Object.freeze({
    accepted: true,
    principalCount: nextPrincipal,
    globalCount: this.globalCount,
    release: () => {
      if (released) return;
      released = true;
      if (generation !== this.generation) return;

      // decrement the owned reservation
    },
  });
}

Add a test that reserves, resets, reserves again, and then calls the first release handle. Verify that the second reservation remains counted.

I found no other issue in the requested areas. The principal HMAC inputs are domain-separated. The token-bucket overflow state is bounded by surface count. The monotonic clock handling prevents token minting after rollback. The retry and reset values use integer ceiling arithmetic.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>

Copy link
Copy Markdown
Author

[code]smith (@codesmith-bot) Continue implementation on the existing feat/admission-rate-limit branch only. Do not open another PR. Implement the remaining admission-aware integration from structure/plugin-metrics-ratelimit-benchmarks.md, preserving the current primitives and tests.

Required design:

  1. Canonical config
  • Add optional, backward-compatible rate-limit settings through the existing OcxConfig type + canonical Zod/config load/save path. No generic migration.
  • Existing configs and runtime behavior must remain unchanged: feature default is disabled.
  • Include explicit enabled, explicit loopbackBypass (default false when enabled), bounded maxBuckets, staleAfterMs, fixed per-surface {requestsPerMinute, burst} policies, and WebSocket {perPrincipal, global} concurrency limits.
  • Reject unknown surfaces, non-finite/non-positive rates, non-integer burst/caps, impossible bounds, and contradictory enabled settings through the canonical schema.
  • Do not store or expose the HMAC secret; it remains process-local.
  1. Principal resolution stays inside auth boundaries
  • Refactor/add helpers in src/server/auth-cors.ts and src/server/management-auth.ts so successful auth can return an opaque RateLimitPrincipal without exposing the raw credential to general middleware.
  • Selection order: validated OpenCodex admission credential; validated management admin token or GUI session; trustworthy requestServer.requestIP(req)?.address; shared anonymous principal.
  • Never use Origin, provider/account/model IDs, forwarded IP headers, email, prompt/body content, or an unvalidated Authorization value.
  • Invalid/unauthenticated attempts may use trustworthy socket address or anonymous fallback, but must not turn an unvalidated presented key into its own bucket.
  1. Admission integration
  • Instantiate process-wide token and WebSocket limiters once per server process; repeated startServer(0) in tests must not leak state across isolated server instances. Provide explicit reset/dispose boundaries for tests/server shutdown.
  • Apply management request limiting to /api/* and /metrics using the same auth/origin routing already present.
  • Apply fixed surface policies to model discovery, Responses HTTP, Responses WebSocket handshake, Chat Completions, Claude Messages/count_tokens, images, search, and live endpoints.
  • Charge WebSocket request rate before upgrade, reserve concurrency before handshake completion, roll back when server.upgrade fails, store only an idempotent release handle on socket data, and release on every close/error/forced-close path.
  • Preserve existing auth and origin response precedence unless the caller is already over the anonymous/remote attempt limit; document the chosen precedence in tests.
  • loopbackBypass may only use Bun’s trusted socket address or the actual bound loopback listener, never Origin or forwarded headers.
  1. 429 responses
  • Return integer Retry-After, RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers.
  • Use the existing OpenAI error envelope for OpenAI-compatible data-plane surfaces, the existing Anthropic envelope for Claude routes, and the existing JSON management response shape for management endpoints.
  • Never include principal/fingerprint values in responses or ordinary logs.
  1. Observability and diagnostics
  • Expose only aggregate allow/deny and WebSocket concurrency counts through the existing metrics lane API if it is present after syncing dev; otherwise keep a typed aggregate snapshot ready for the metrics lane to consume without raw principals.
  • Add safe config diagnostics only if the current config command already has a diagnostics layer; do not invent lane 4 early.
  1. Tests and docs
  • Add focused integration tests proving: feature-off compatibility; distinct admitted keys isolate buckets; spoofed Origin cannot bypass; invalid keys share remote/anonymous fallback instead of creating buckets; loopback bypass is explicit; correct OpenAI/Anthropic/management 429 envelopes and integer headers; WebSocket reservation rollback and release; global/per-principal WS caps; bucket hard-cap overflow; repeated server starts do not leak state.
  • Add user-facing configuration and endpoint behavior docs under docs-site/ in the existing style.
  • Run bun run typecheck, focused rate-limit tests, full tests, and privacy scan. Push directly to this branch.

Keep runtime changes scoped to rate limiting. Do not add dynamic plugins, another logger, another config validator, raw fingerprint diagnostics, or provider/model labels.

@blacksmith-sh

blacksmith-sh Bot commented Aug 2, 2026

Copy link
Copy Markdown

Got it, I'm reviewing the PR comments first.

@OnlineChef
OnlineChef marked this pull request as ready for review August 2, 2026 06:51
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing

Copy link
Copy Markdown
Author

greptile-apps Please review the current ready head 2527dc5 as a pure, intentionally unused security primitive lane. Verify fingerprint opacity/domain separation, hard-cap overflow, retry arithmetic, stale eviction, clock rollback, concurrency limits, idempotent release, and reset-generation isolation. No endpoint integration exists in this PR.

@OnlineChef
OnlineChef merged commit 6b6712f into dev Aug 2, 2026
15 checks passed
Comment thread src/ratelimit/token-bucket.ts
@blacksmith-sh

blacksmith-sh Bot commented Aug 2, 2026

Copy link
Copy Markdown

OnlineChef

[code]smith (@codesmith-bot) Continue implementation on the existing feat/admission-rate-limit branch only. Do not open another PR. Implement the remaining admission-aware integration from `structure/plugin-metri...

One important state change first: PR #44 was merged into dev while this task was queued, so pushing more commits onto feat/admission-rate-limit would have dirtied a merged PR's history. I kept every commit from that branch intact (they're in dev via the merge) and landed the integration as a new branch off the latest dev instead: #45 (CI autofix enabled).

The PR implements the full admission integration from structure/plugin-metrics-ratelimit-benchmarks.md §3 on top of the merged primitives, default OFF:

  • Config: optional rateLimit block on OcxConfig with a strict Zod subtree in the canonical schema; rejects unknown surfaces, non-finite/non-positive rates, non-integer burst/caps, perPrincipal > global, and loopbackBypass without enabled. The HMAC secret stays process-local, never configured or persisted.
  • Principals resolved inside auth-cors.ts / management-auth.ts in the required order (validated admission key → validated admin token/GUI session → trusted requestIP address → shared anonymous); invalid keys never mint buckets.
  • Integration: per-server admission control (src/server/rate-limit.ts) with a two-phase gate on all nine surfaces; anonymous/remote over-limit 429 preempts auth, validated principals keep 401/403 precedence (documented in tests). WS handshakes charge rate pre-upgrade, reserve concurrency before the handshake, roll back on server.upgrade failure, and release idempotently on every close path.
  • 429s carry integer Retry-After/RateLimit-* headers in the surface-native envelope (OpenAI/Anthropic/management JSON); no metrics lane exists on dev yet, so aggregate counts ship as a typed RateLimitAggregateSnapshot ready for it. No config-diagnostics layer exists either, so lane 4 was not invented early.
  • Tests/docs: 17 new integration/unit tests covering every required scenario (30 pass with the primitives suite), docs added to docs-site reference/configuration. typecheck, privacy:scan, and the docs build pass; the full suite's only failures are pre-existing sandbox-environment issues (CLI path assertions, missing GUI node_modules) identical on a clean checkout.

View with [code]smith

Copy link
Copy Markdown
Author

Follow-up hotfix opened as #47. It persists the governing policy per principal/overflow bucket, refills elapsed time under the prior policy, then applies the new burst without minting tokens. The original P1 thread should remain unresolved until its controlled-clock reproduction passes against #47.

@OnlineChef

Copy link
Copy Markdown
Author

Architecture triage 2026-08-02 (donor, not future SSOT)

State: MERGED → dev @ 6b6712f1af2621ea8392e696351f6056b7e3dab6 (head 2527dc56…). Not on main. Parent of metrics merge #42.

Classification: KEEP_AS_DONOR_IN_DEV (+ follow-ups supersede wiring). Inert primitives only (no route activation). Known P1 policy-transition bug tracked by open #47/#48; runtime wiring drafts #45/#49. Treat as donor algorithms (HMAC principal fingerprint, token-bucket, WS concurrency caps), not automatic Rust/Pi architecture.

Vault-auth: none in this diff. Explicitly deferred principal-from-auth and route wiring. Cross-repo Vault e2e is unrelated to this merge-ready status (already merged).

Donor extraction candidates: src/ratelimit/{principal,token-bucket,concurrency,index}.ts + tests/ratelimit.test.ts. Prefer landing one policy-transition fix then freeze further TS expansion unless Sofie interim runtime needs it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant