Skip to content

feat: ✨ Session budget enforcement plugin - #723

Open
evaline-ju wants to merge 6 commits into
rossoctl:mainfrom
evaline-ju:budget-track
Open

feat: ✨ Session budget enforcement plugin#723
evaline-ju wants to merge 6 commits into
rossoctl:mainfrom
evaline-ju:budget-track

Conversation

@evaline-ju

@evaline-ju evaline-ju commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds a opt-in (include_plugin_tokenbudget) token-budget plugin for per-session lifetime enforcement for tokens, calls, and wall-clock duration, working with streaming SSE responses and buffered responses
  • Include shadow mode (on_exceed: "observe") as alternative to denial
  • Behavior on denial is 403 - note: for errors on the outbound plugin pipeline, like the token-exchange plugin, Cortex as a sidecar will not be able to surface these errors clearly through agents themselves
  • Introduces a storage interface - note: the plugin depends on the interface, not Redis directly, Redis here provides a storage example for persisting count info across pod restarts and replicas
  • Test coverage: unit, e2e
  • Documentation in docs/token-budget-plugin.md with config reference, pipeline position

Related issue(s)

Closes #708 , future: extend with HITL

Testing Instructions

  • Deploy Valkey (e.g. helm install valkey bitnami/valkey -n <ns>) or any Redis-compatible store
  • Build with tag: cd authbridge && docker build -f cmd/authbridge-proxy/Dockerfile \ --build-arg GO_BUILD_TAGS="include_plugin_tokenbudget" \ -t authbridge:latest . and load into cluster
  • Add the plugin to the outbound pipeline through authbridge-runtime-config (or see docs/token-budget-plugin.md), roll pod or wait for hot-reload
- name: token-budget
  config:
    redis_url: "redis://valkey.<ns>.svc:6379"
    max_tokens: 100
    max_calls: 3
    on_exceed: "observe"
  • With on_exceed: "observe", send agent requests past limit, these requests should succeed and info can be seen in logs/through abctl
  • To flip to on_exceed: "deny", edit the authbridge configmap

Assisted-By: Claude (Anthropic AI)

Summary by CodeRabbit

  • New Features

    • Added an opt-in token budget plugin enforcing per-session token, inference-call, and duration limits.
    • Added Redis/Valkey-backed persistence, caching, recovery, and outage handling.
    • Added shadow mode for monitoring limits without rejecting requests.
    • Requests exceeding configured budgets now receive HTTP 403 responses.
  • Bug Fixes

    • Final streaming response processing now completes after client cancellation.
  • Documentation

    • Added configuration, deployment, behavior, and troubleshooting guidance.
  • Tests

    • Added coverage for limits, streaming, persistence, outages, recovery, and storage operations.

Implements per-session lifetime budgets on tokens, inference calls,
and wall-clock duration. Uses Redis for cross-pod durable counters
with a local in-memory cache for zero-latency evaluation on the
request path. Fail-open by default when Redis is unavailable.

Includes unit tests, e2e tests (forward proxy round-trip, accumulate
and deny, multi-session isolation, Redis failure/recovery, pod restart),
storage interface with driver registry, Redis driver, and user-facing
plugin documentation.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a228d95b-d61b-4e69-88c3-5d0c88990733

📥 Commits

Reviewing files that changed from the base of the PR and between 6f6fe97 and 80f6b43.

📒 Files selected for processing (6)
  • authbridge/authlib/plugins/tokenbudget/e2e_test.go
  • authbridge/authlib/plugins/tokenbudget/plugin.go
  • authbridge/authlib/plugins/tokenbudget/plugin_test.go
  • authbridge/cmd/authbridge-envoy/Dockerfile
  • authbridge/docs/token-budget-plugin.md
  • authbridge/storage/redis/redis_test.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • authbridge/docs/token-budget-plugin.md
  • authbridge/storage/redis/redis_test.go
  • authbridge/authlib/plugins/tokenbudget/plugin_test.go
  • authbridge/authlib/plugins/tokenbudget/plugin.go
  • authbridge/authlib/plugins/tokenbudget/e2e_test.go

📝 Walkthrough

Walkthrough

Adds an opt-in token-budget plugin with Redis-backed per-session token, call, and duration limits. It supports enforcement and observe modes, cache refresh, failure handling, build integration, documentation, and comprehensive tests.

Changes

Token budget plugin

Layer / File(s) Summary
Storage contracts and Redis backend
authbridge/authlib/storage/*, authbridge/storage/redis/*
Adds the storage.Store interface, provider registry, and Redis/Valkey implementation with counters, hashes, TTLs, and cleanup.
Budget lifecycle and enforcement
authbridge/authlib/plugins/tokenbudget/plugin.go, authbridge/authlib/pipeline/action.go, authbridge/authlib/listener/forwardproxy/server.go
Adds configuration, lifecycle handling, cached budget evaluation, Redis accumulation, refresh polling, observe mode, budget.exceeded mapping, and cancellation-safe final response dispatch.
Plugin lifecycle and enforcement validation
authbridge/authlib/plugins/tokenbudget/*_test.go
Adds unit, lifecycle, and end-to-end tests for limits, sessions, outages, recovery, restarts, and observe mode.
Opt-in build integration and documentation
authbridge/cmd/authbridge-envoy/*, authbridge/cmd/authbridge-proxy/*, authbridge/docs/token-budget-plugin.md
Adds build-tagged imports, local module wiring, Docker build input, and token-budget configuration documentation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Pipeline
  participant TokenBudget
  participant Redis
  Pipeline->>TokenBudget: Check cached session budget
  TokenBudget-->>Pipeline: Allow or budget.exceeded denial
  Pipeline->>TokenBudget: Process final response frame
  TokenBudget->>Redis: Persist token and call counters
  TokenBudget->>TokenBudget: Refresh cached session state
Loading

Suggested reviewers: huang195

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The plugin covers session token, call, and duration enforcement, observe mode, Redis persistence, tests, and docs, but returns 403 instead of the required 429 response for issue #708. Return HTTP 429 with the specified budget_exceeded payload, including reason, spent, and limit, then update tests and documentation.
Docstring Coverage ⚠️ Warning Docstring coverage is 10.34% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: a session budget enforcement plugin.
Out of Scope Changes check ✅ Passed The changes support the session budget plugin through storage, integration, streaming finalization, tests, build wiring, and documentation.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@evaline-ju
evaline-ju marked this pull request as ready for review August 10, 2026 18:19
@evaline-ju
evaline-ju requested a review from a team as a code owner August 10, 2026 18:19

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@authbridge/authlib/listener/forwardproxy/server.go`:
- Around line 610-615: Update the finalization flow around RunResponseFrame to
wrap context.WithoutCancel(r.Context()) with a short context.WithTimeout before
dispatch. Use the resulting context for the final last=true call and ensure its
cancel function is released after dispatch, while preserving detached
cancellation behavior.

In `@authbridge/authlib/plugins/tokenbudget/plugin.go`:
- Around line 255-261: The RedisUnavailable configuration accepts fail_closed
even though refresh failures still allow cache-miss sessions to continue. In the
configuration validation path, reject the fail_closed value until enforcement is
implemented, while preserving supported modes such as fail_open and the existing
refresh behavior.
- Around line 72-103: Validate p.cfg.RefreshInterval in TokenBudget.Configure by
parsing it with time.ParseDuration and returning a configuration error when the
duration is zero or negative; preserve the existing default and valid-duration
behavior. Add validation tests covering "0s" and "-1s", ensuring both are
rejected before Init can start refreshLoop.
- Around line 166-183: Update the inference handling around
pctx.Extensions.Inference to return early only when inf is nil. For completed
responses with TotalTokens equal to zero, retain the zero token delta while
still initializing the session counters, incrementing calls, and preserving the
existing cache update flow.
- Around line 126-128: Update the cache-miss path around the visible !ok check
in the token-budget request flow to load the session’s persisted counters from
Redis before returning pipeline.Continue; do not let an uncached session bypass
budget enforcement, while preserving normal refresh behavior for existing keys.
Add or update the preload logic needed for first requests and revise
TestE2E_PodRestart to assert that over-budget sessions remain blocked after a
replica restart.
- Around line 122-145: The cache lookup in the token-budget evaluation path must
use a stable counter snapshot. In the code around p.evaluate and the shadow-mode
log, copy tokens, calls, and startedAt from the cached counters while holding
p.mu.RLock(), then unlock and evaluate/log the copied value instead of the
shared c instance.
- Around line 106-110: Update TokenBudget.Shutdown and the accumulate goroutine
to track pending persistence work, wait for all queued writes using a bounded
shutdown context, and only then close p.store. Ensure shutdown proceeds after
the timeout while preventing accumulate from writing against a closed store.

In `@authbridge/cmd/authbridge-envoy/go.mod`:
- Around line 5-13: Align the authbridge-envoy Docker build context with the
storage/redis module replacement in go.mod by ensuring the Dockerfile makes
storage/ available at the expected relative path, either through a matching COPY
storage/ storage/ directive or an equivalent build context that includes it.
Preserve the existing authlib and authbridge-envoy build inputs.

In `@authbridge/docs/token-budget-plugin.md`:
- Around line 11-19: Update token-budget plugin documentation to clarify its
deployment scope: either add the corresponding Envoy Docker build procedure
using include_plugin_tokenbudget, or explicitly state that the plugin is
supported only by the proxy and not Envoy. Ensure the documented guidance
matches the behavior enabled by plugins_tokenbudget.go.
- Around line 104-105: Update the token-budget enforcement flow documented in
the pod-restart and fail_closed rows so requests cannot bypass exhausted
Redis-backed session budgets: load current session state before admitting the
request, and when refresh fails under fail_closed, deny the request rather than
retaining stale state. If stale-cache enforcement is intentional, rename
fail_closed and document that behavior instead.
- Around line 91-96: Add the `text` language tag to the schema code fence in the
token budget documentation, changing the opening fence to specify `text` while
leaving the schema content unchanged.
- Line 102: Update the Redis down at startup row in the token-budget behavior
table to reflect that token-budget.Init succeeds without connectivity
validation, while failure occurs later during lazy access or refresh operations.
Remove the incorrect Pod Init error outcome and describe the actual refresh-path
behavior.

In `@authbridge/storage/redis/redis_test.go`:
- Line 18: Update the Redis tests to check and fail on the error returned by
c.Close in t.Cleanup, and validate every HashIncr result before proceeding,
especially before calling Expire in TestExpire. Ensure a failed HashIncr cannot
allow Expire to continue as a no-op and produce a false passing test.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4f19bf22-3c03-4cee-a995-66532013ca4b

📥 Commits

Reviewing files that changed from the base of the PR and between 510ce11 and 6f6fe97.

⛔ Files ignored due to path filters (4)
  • authbridge/cmd/authbridge-envoy/go.sum is excluded by !**/*.sum
  • authbridge/cmd/authbridge-proxy/go.sum is excluded by !**/*.sum
  • authbridge/go.work is excluded by !**/*.work
  • authbridge/storage/redis/go.sum is excluded by !**/*.sum
📒 Files selected for processing (18)
  • authbridge/authlib/listener/forwardproxy/server.go
  • authbridge/authlib/pipeline/action.go
  • authbridge/authlib/plugins/tokenbudget/e2e_test.go
  • authbridge/authlib/plugins/tokenbudget/lifecycle_test.go
  • authbridge/authlib/plugins/tokenbudget/plugin.go
  • authbridge/authlib/plugins/tokenbudget/plugin_test.go
  • authbridge/authlib/storage/provider.go
  • authbridge/authlib/storage/provider_test.go
  • authbridge/authlib/storage/store.go
  • authbridge/cmd/authbridge-envoy/go.mod
  • authbridge/cmd/authbridge-envoy/plugins_tokenbudget.go
  • authbridge/cmd/authbridge-proxy/Dockerfile
  • authbridge/cmd/authbridge-proxy/go.mod
  • authbridge/cmd/authbridge-proxy/plugins_tokenbudget.go
  • authbridge/docs/token-budget-plugin.md
  • authbridge/storage/redis/go.mod
  • authbridge/storage/redis/redis.go
  • authbridge/storage/redis/redis_test.go

Comment on lines +610 to +615
// Use a detached context for finalization: the client may have
// cancelled the request context after reading the full stream,
// but aggregating plugins (inference-parser, token-budget) still
// need their last=true dispatch to finalize state.
finalCtx := context.WithoutCancel(r.Context())
finalAction := s.OutboundPipeline.RunResponseFrame(finalCtx, pctx, nil, true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound detached response finalization.

context.WithoutCancel removes both cancellation and the request deadline. If a streaming responder blocks during RunResponseFrame, this handler can block indefinitely after the client disconnects.

Wrap the detached context with a short context.WithTimeout before final dispatch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@authbridge/authlib/listener/forwardproxy/server.go` around lines 610 - 615,
Update the finalization flow around RunResponseFrame to wrap
context.WithoutCancel(r.Context()) with a short context.WithTimeout before
dispatch. Use the resulting context for the final last=true call and ensure its
cancel function is released after dispatch, while preserving detached
cancellation behavior.

Comment thread authbridge/authlib/plugins/tokenbudget/plugin.go
Comment on lines +106 to +110
func (p *TokenBudget) Shutdown(_ context.Context) error {
close(p.stopCh)
<-p.stopped
if p.store != nil {
return p.store.Close()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Drain persistence work before closing the store.

Line 173 starts an untracked accumulate goroutine. Shutdown waits only for refreshLoop, then closes p.store at line 110. A pending accumulation can run after close and lose a completed request's counters.

Track persistence tasks and wait for them with a bounded shutdown context. Do not close the store until queued writes finish or time out.

Also applies to: 173-173

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@authbridge/authlib/plugins/tokenbudget/plugin.go` around lines 106 - 110,
Update TokenBudget.Shutdown and the accumulate goroutine to track pending
persistence work, wait for all queued writes using a bounded shutdown context,
and only then close p.store. Ensure shutdown proceeds after the timeout while
preventing accumulate from writing against a closed store.

Comment thread authbridge/authlib/plugins/tokenbudget/plugin.go
Comment on lines +126 to +128
if !ok {
return pipeline.Action{Type: pipeline.Continue}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Load persisted counters before allowing a cache-miss request.

Line 127 allows every session that is absent from the local cache. refreshCache only refreshes existing cache keys. A fresh replica therefore allows requests for a session that already exceeded its Redis budget.

Load the session counters on the first request, or maintain a safe preload mechanism. Update TestE2E_PodRestart so it does not accept this enforcement bypass.

Also applies to: 241-281

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@authbridge/authlib/plugins/tokenbudget/plugin.go` around lines 126 - 128,
Update the cache-miss path around the visible !ok check in the token-budget
request flow to load the session’s persisted counters from Redis before
returning pipeline.Continue; do not let an uncached session bypass budget
enforcement, while preserving normal refresh behavior for existing keys. Add or
update the preload logic needed for first requests and revise TestE2E_PodRestart
to assert that over-budget sessions remain blocked after a replica restart.

Comment thread authbridge/docs/token-budget-plugin.md
Comment thread authbridge/docs/token-budget-plugin.md Outdated
Comment thread authbridge/docs/token-budget-plugin.md Outdated
Comment thread authbridge/docs/token-budget-plugin.md Outdated
Comment on lines +104 to +105
| Pod restarts | First request passes (cold cache); refresh picks up Redis counters within one interval |
| `fail_closed` + refresh failure | Stale cache retained; enforcement lags until Redis recovers |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Prevent stale cache state from bypassing exhausted budgets.

After a restart, Line 104 says that the first request passes before Redis refreshes the local cache. Line 105 also says that fail_closed retains stale cache state during refresh failures. If Redis already records an exhausted session, either path can admit traffic beyond the configured limit. Load session state before admitting the request. For true fail_closed behavior, deny while refresh fails, or rename and document the mode as stale-cache enforcement.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@authbridge/docs/token-budget-plugin.md` around lines 104 - 105, Update the
token-budget enforcement flow documented in the pod-restart and fail_closed rows
so requests cannot bypass exhausted Redis-backed session budgets: load current
session state before admitting the request, and when refresh fails under
fail_closed, deny the request rather than retaining stale state. If stale-cache
enforcement is intentional, rename fail_closed and document that behavior
instead.

Comment thread authbridge/storage/redis/redis_test.go Outdated
@rubambiza rubambiza added the ready-for-ai-review Request automated AI code review from clawgenti label Aug 10, 2026

@clawgenti clawgenti left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New opt-in token-budget plugin enforcing per-session token, call, and duration limits via Redis, with shadow mode and a pluggable storage abstraction — solid design overall. Two findings worth addressing before merge.

  • Goroutine leak on shutdown: accumulate is fired as a detached goroutine with no WaitGroup; Shutdown closes the store while in-flight accumulate goroutines may still be writing to it.
  • fail_closed is not implemented: the config accepts fail_closed and docs describe it as "log warnings; stale cache retained", but the TODO comment confirms it currently behaves identically to fail_open. Either remove the option or implement it before merge to avoid user confusion.

Reviewed by clawgenti using the github-pr-review skill

tokens := int64(inf.TotalTokens)

go p.accumulate(sessionID, tokens)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Goroutine leak on shutdown: accumulate is fire-and-forget with no WaitGroup. When Shutdown is called, p.store.Close() may race with in-flight goroutines still writing to the store. Consider adding a sync.WaitGroup to track outstanding accumulate calls and waiting on it before closing the store.


if err != nil {
// TODO: fail_closed should deny requests when Redis is unreachable
// and the local cache has no data. Currently both modes retain stale cache.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unimplemented fail_closed: The config option is accepted and documented, but the TODO comment confirms it currently behaves the same as fail_open (stale cache retained, no enforcement difference). Remove the option from the public API or implement the intended behavior (e.g., deny requests when the store is unreachable) before shipping.

Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>

@clawgenti clawgenti left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR introduces a solid, well-structured token-budget plugin with clean storage abstraction, good test coverage across unit/e2e/lifecycle scenarios, and a sensible fail-open default for Redis outages. Two findings worth addressing before merge.

Findings:

  • Init() hardcodes storage.Open("redis", ...) regardless of the URL scheme, breaking the pluggable registry for any non-redis:// scheme (e.g. valkey://).
  • Configure() silently ignores refresh_interval parse errors (the err == nil guard means invalid strings pass validation; Init() then silently falls back to 5s with no user-visible error).

Reviewed by clawgenti using the github-pr-review skill

}

func (p *TokenBudget) Init(_ context.Context) error {
store, err := storage.Open("redis", p.cfg.RedisURL)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

storage.Open("redis", ...) hardcodes the scheme instead of deriving it from the URL. This bypasses the pluggable registry — a valkey:// URL (which the docs advertise as supported) would fail with unknown scheme. Consider parsing the scheme from p.cfg.RedisURL via url.Parse and passing it to Open, or documenting that only redis:// is valid here.

if p.cfg.OnExceed != "deny" && p.cfg.OnExceed != "observe" {
return fmt.Errorf("token-budget: on_exceed must be \"deny\" or \"observe\" (got %q)", p.cfg.OnExceed)
}
if d, err := time.ParseDuration(p.cfg.RefreshInterval); err == nil && d <= 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guard if d, err := time.ParseDuration(...); err == nil && d <= 0 only rejects non-positive durations when the string is valid. An invalid string (e.g. "abc") silently passes Configure() and Init() then falls back to 5s without any error or warning. Consider returning an error on parse failure here too: if _, err := time.ParseDuration(p.cfg.RefreshInterval); err != nil { return fmt.Errorf(...) }.

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

Labels

ready-for-ai-review Request automated AI code review from clawgenti

Projects

Status: New/ToDo

Development

Successfully merging this pull request may close these issues.

epic: Session Lifetime Cap Plugin

3 participants