Skip to content

Cap DCR clients and expire unused ones after 24h - #6863

Merged
tgrunnagle merged 10 commits into
mainfrom
chambray-amount
Oct 20, 2026
Merged

tgrunnagle merged 10 commits into
mainfrom
chambray-amount

Conversation

@tgrunnagle

@tgrunnagle tgrunnagle commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

/oauth/register is unauthenticated, and every successful call persists a client row. On Redis nothing capped those rows, and a client that was never used lived 30 days, so the row count was limited only by rate × TTL: about 2.6M rows per replica. The memory store did have a cap, but enforced it by evicting idle clients, so anyone registering at the full rate could push out real clients and break their refresh tokens without the operator seeing it. This PR limits how much open registration can cost on both backends without evicting anyone.

  • Hard cap, no eviction, on both backends. Only DCR-issued clients count; operator-configured and CIMD clients don't. At the cap, RegisterClient returns ErrClientCapacity and writes nothing. The 503 Retry-After is the number of seconds until the oldest counted client expires. On Redis, a sorted set of DCR client IDs scored by expiry time is the count, and it lives under the same hash-tagged prefix as the client rows. One Lua script trims expired entries, checks the cap, and writes the row together with its index entry, so the cap holds across replicas. A second script renews a row's TTL and its index score together, and ReconcileConfiguredClient drops any stale index entry for its ID in the same WATCH/MULTI transaction as the row. UpsertDCRIssuedClient (the CIMD path) refuses counted clients, so it can't bypass the cap. Redis storage has no client-delete path, so there is nothing to keep in step there.
  • Upgrade backfill. At startup, a migration adds existing DCR rows to the index with their current TTL so they count against the cap. If the backfill fails, the server logs a WARN and starts anyway; if existing clients already fill the cap, it logs a WARN naming storage.max_dcr_clients.
  • Unused clients expire early. A newly registered client gets DefaultUnusedDCRClientTTL (24h). The existing RenewClientTTL call on the first successful token exchange raises it to 30 days. This lowers the worst case to about 86,400 rows per replica. The memory store now uses the same expiry rules, so a full map drains on its own. minClientAge and DefaultMinClientAge are removed.
  • Configurable register rate. registration_rate_limit (requests_per_second, burst) replaces the hardcoded 1/s burst 5, which stays the default.
  • Visibility. A live-client gauge, read at collection time rather than on the request path, and a rejection counter. Rejections are also logged at WARN, at most once every 15 minutes, with the cap, the live count, Retry-After and the number of rejections held back. The volume is bounded by time rather than by the unauthenticated caller.

Closes #6861

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)

  • E2E tests (task test-e2e)

  • Linting (task lint-fix)

  • Manual testing (describe below)

  • Unit tests: full task test passed except pkg/container/docker TestCreateNetwork_ConcurrentSameName. That test is unrelated and fails locally because Docker reports "all predefined address pools have been fully subnetted". CI's Tests / Test Go Code job passes on 040b16a, along with every other check, including the E2E and operator suites.

  • Linting: task lint passes.

  • Integration: ran the integration-tagged Redis tests in pkg/authserver/storage/redis_dcr_cap_integration_test.go locally against redis:7-alpine (Sentinel, via testcontainers), on the current scripts. TestIntegration_DCRCap passes all four subtests: rejection at the cap with no row written, two storage instances racing never exceeding the cap, TTL and index score following registration and first use, and backfill. Slot release on expiry is covered by the miniredis unit tests (TestRedisStorage_DCRCap_FreesOnExpiry), not the integration tests. No CI workflow runs task test-integration for this package, so this local run is the only one against real Redis.

  • Unrelated failure in the same package: TestIntegration_ClientAssertionJWT/known_JTI_is_invalid fails for a reason that has nothing to do with this PR: the test, which this PR does not touch, sets a 1h assertion lifetime, and Add private_key_jwt DCR client authentication #6427 capped lifetimes at 5m.

Changes

File Change
pkg/authserver/storage/redis.go, redis_keys.go DCR index sorted set; Lua scripts for capped register and renew; index maintenance in ReconcileConfiguredClient; UpsertDCRIssuedClient refuses counted clients; CountDCRClients
pkg/authserver/storage/redis_migrate.go Startup backfill of existing DCR rows into the index
pkg/authserver/storage/memory.go, cimd_decorator.go Removes eviction; reject-only DCR cap; separate cap on CIMD rows; DCR expiry; CountDCRClients
pkg/authserver/storage/dcr_cap.go, types.go, config.go ClientCapacityError carrying Retry-After, DCRClientCounter, new TTL/cap defaults, max_dcr_clients
pkg/authserver/server/handlers/dcr.go, handler.go, token.go Cap-derived Retry-After, configurable register limiter, metrics wiring
pkg/authserver/server/handlers/dcr_metrics.go, dcr_capacity_warner.go Live-client gauge, capacity rejection counter, and the time-bounded cap WARN
pkg/authserver/config.go, server_impl.go, runner/embeddedauthserver.go registration_rate_limit config, validation, and plumbing
docs/arch/11-auth-server-storage.md, docs/redis-storage.md, docs/observability.md Cap, sizing, index, backfill, upgrade notes, TTL, metrics and alerting documentation
docs/server/*, sdk/go/* Generated by task docs
*_test.go Unit tests for the handler, config and both backends; Redis integration tests

Does this introduce a user-facing change?

Yes.

  • Redis: DCR clients are now capped (release note). Before: no limit on DCR clients. After: capped at 10,000 by default, set with storage.max_dcr_clients (0 = unlimited). At the cap, /oauth/register returns 503 with server_error and a Retry-After equal to the seconds until the oldest counted client expires. A deployment that already holds more than 10,000 live DCR clients will reject new registrations right after upgrading until the count drops below the cap. To avoid this, raise max_dcr_clients or set it to 0 before upgrading. The operator CRD fields come in a stacked PR for Expose DCR client cap and register rate limit in the operator CRD #6864, planned for the same release. At startup, the server logs a WARN when existing clients already fill the cap. Backfilled rows keep their current TTL of up to 30 days. No Redis ACL change is needed: the scripts use only commands the documented ACL already grants.
  • Memory store at the cap. Before: evicted the least-recently-used DCR client older than 1h, and returned 503 with a fixed Retry-After: 3600 only when no client was old enough. After: always rejects without evicting, and Retry-After is the time until the oldest counted client expires. CIMD rows are no longer counted against that cap; they get their own cap of 10,000 instead, and past it the best-effort CIMD write-through skips the row.
  • Unused DCR client lifetime. Before: 30 days whether or not the client was used. After: 24h until the first successful token exchange, which raises the TTL to 30 days, renewed on each later exchange as before.
  • Register rate limit. Before: hardcoded 1/s with a burst of 5. After: configurable with registration_rate_limit (requests_per_second, burst); unset keeps 1/s burst 5. The limiter is per process, so the effective rate is the configured rate × the replica count. The 429 Retry-After is now ceil(1/rate) instead of a fixed 1, which gives the same value at the default rate.
  • New metrics. Gauge toolhive_authserver_dcr_clients and counter toolhive_authserver_dcr_capacity_rejections.
  • New WARN logs. DCR client cap reached; rejecting new client registrations, at most once every 15 minutes while registrations are being rejected. On Redis, also one line at startup when the backfilled count is already at or above the cap, and one if the backfill fails.

Special notes for reviewers

  • PR size. This PR is over the 400-line / 10-file guideline because the user chose one PR for the whole issue. A good share of the diff is tests and generated files (docs/server/* and sdk/go/*, from task docs).
  • CIMD clients are not counted against the DCR cap, as the issue requires. On the memory backend they get a separate cap of 10,000 rows, replacing the bound the old whole-map cap used to give them. On Redis, CIMD rows are still bounded only by the per-process CIMD authorize rate limit and their 30-day TTL.
  • Index scores use the replica clock, passed to the scripts as an argument, rather than Redis TIME. TIME is in the @fast ACL category, which the documented auth-server ACL doesn't grant, so using it would break ACL-restricted deployments on upgrade. Clock skew between replicas only shifts when an index entry is trimmed relative to its row's expiry.
  • Backfill. It runs on every startup, and on Redis Cluster it scans the master that owns the slot. It skips rows it can't parse and logs one WARN line for them. A Redis error fails only the backfill, not startup: skipping it just leaves the cap undercounting older rows until the next restart, which is how the server behaved before the cap. The legacy upstream migrations stay fatal. It logs one INFO line with the rows scanned, the clients added, and the duration. Rows that old replicas write during a rolling upgrade stay uncounted until the next restart or until they expire.
  • UpsertDCRIssuedClient refuses counted clients on both backends. Its only caller is the CIMD decorator, and counted clients have to go through RegisterClient, which enforces the cap.
  • Follow-ups: operator CRD fields for max_dcr_clients and registration_rate_limit (Expose DCR client cap and register rate limit in the operator CRD #6864), as a stacked PR for the same release; the same Redis Cluster SCAN flaw in the existing legacy upstream migrations in redis_migrate.go (not filed).
  • Two internal code-review passes ran. The first found 1 high, 4 medium and 1 low issue; the second found 2 low issues. All are fixed. All 18 findings from the multi-agent review are addressed in 5fb2bf9, d98fe26 and b25181b. The follow-up CI failures (SDK schema count, codespell) are fixed in fae8ff0. The operational review's backfill and logging notes are addressed in f6781fc and 040b16a.

🤖 Generated with Claude Code

tgrunnagle and others added 3 commits October 19, 2026 09:27
/oauth/register is unauthenticated and every call persists a client.
Redis had no cap, so live rows were bounded only by rate x TTL (about
2.6M per replica), and the memory store's eviction let a caller
registering at full rate push out every idle real client.

Implements #6861:
- Cap live DCR-issued clients on both backends (default 10,000, 0 is
  unlimited), reject-only with a Retry-After from the oldest expiry.
  Configured and CIMD clients are not counted.
- On Redis, count via a hash-tagged sorted set updated atomically by a
  Lua register script and on every write/renew path, with a startup
  backfill for rows written before the index existed.
- Remove memory-store eviction; give DCR entries real expiry instead.
- New DCR clients get a 24h TTL; the first token exchange raises it to
  the existing 30-day TTL through RenewClientTTL.
- Make the /oauth/register rate and burst configurable.
- Add a live DCR client gauge and a capacity rejection counter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixed issues from code review:
- HIGH: Scan the node owning the key prefix's slot so the DCR index
  backfill works on Redis Cluster
- MEDIUM: Skip unparseable client rows in the backfill instead of
  failing startup, and batch its reads per SCAN page
- MEDIUM: Derive the 429 Retry-After from the configured register rate
- MEDIUM: Unregister the live-client gauge callback on server Close
- MEDIUM: Document that CIMD rows are not bounded by the DCR cap
- LOW: Score the DCR index from the Redis server clock (TIME)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- LOW: Clamp the 429 Retry-After so a tiny configured rate cannot
  overflow int
- LOW: Document that rows written by old replicas during a rolling
  upgrade stay uncounted until the next backfill or their expiry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added the size/XL Extra large PR: 1000+ lines changed label Oct 19, 2026
@codecov

codecov Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.82051% with 28 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.73%. Comparing base (8aaed56) to head (ca782e8).

Files with missing lines Patch % Lines
pkg/authserver/storage/redis_migrate.go 88.76% 10 Missing ⚠️
pkg/authserver/storage/redis.go 85.96% 8 Missing ⚠️
pkg/authserver/server/handlers/dcr_metrics.go 85.29% 5 Missing ⚠️
pkg/authserver/storage/cimd_decorator.go 50.00% 2 Missing ⚠️
pkg/authserver/server/handlers/handler.go 97.56% 1 Missing ⚠️
pkg/authserver/server_impl.go 90.90% 1 Missing ⚠️
pkg/authserver/storage/memory.go 98.80% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6863      +/-   ##
==========================================
+ Coverage   79.70%   79.73%   +0.03%     
==========================================
  Files         822      825       +3     
  Lines       83843    84154     +311     
==========================================
+ Hits        66824    67102     +278     
- Misses      17014    17047      +33     
  Partials        5        5              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tgrunnagle tgrunnagle left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Multi-Agent Consensus Review

Agents consulted: concurrency-redis, security, memory-store, config-docs, test-coverage, review-friendliness, general

Consensus Summary

# Finding Consensus Severity Action
1 New TIME calls are outside the documented Redis ACL 8/10 HIGH Fix
2 Operator cannot set max_dcr_clients / registration_rate_limit 9/10 MEDIUM Fix or document
3 In-memory backend loses its only bound on CIMD rows 8/10 MEDIUM Discuss
4 Sizing relationship and default-config lockout undocumented 9/10 MEDIUM Fix
5 Upgrade behaviour only in the PR body, not in docs 8/10 MEDIUM Fix
6 No test that startup runs the backfill 7/10 MEDIUM Fix
7 No test that registration_rate_limit reaches the handler 7/10 MEDIUM Fix
8 UpsertDCRIssuedClient counted branch unreachable, bypasses cap, extra TIME 9/10 LOW Fix
9 Unchecked type assertions on the register script reply 7/10 LOW Fix
10 Duplicate-ID vs at-capacity precedence differs between backends 8/10 LOW Fix
11 Register limiter accepts NaN/Inf; 429 Retry-After doc says exact 8/10 LOW Fix
12 No test pins index/row hash-slot co-location 8/10 LOW Fix
13 Exact Retry-After test is clock-dependent; gauge untested via decorators 8/10 LOW Fix
14 Lock-held O(n) scan, stale comment, partial clock injection (memory) 7/10 LOW Fix
15 Cap options treat negative values as unlimited 7/10 LOW Fix
16 Redis deployment requirements for the index undocumented 7/10 LOW Fix
17 New metrics missing from docs/observability.md 7/10 LOW Fix
18 PR body overstates the Redis paths (see below; no inline comment) 8/10 LOW Fix

Rows 1-17 have an inline comment. Row 18 is described under PR description below.

Overall

This PR bounds open DCR registration without evicting anyone: a hash-tagged sorted-set index, a single Lua script that trims, checks the cap and writes the row plus its index entry, scores taken from Redis TIME so replica clock skew cannot desync the index from key expiry, and matching expiry rules on the memory store. The Redis atomicity, Cluster key co-location, unit handling (ms scores against PX), renew/replace/reconcile index maintenance and backfill idempotency all held up under review, and every acceptance criterion in the linked issue has an implementation and at least one test.

One finding should block merge: the new server-side TIME calls are not covered by the ACL that docs/redis-storage.md and the operator examples tell users to create, so on an ACL-restricted deployment registration, renewal and the startup backfill would fail (row 1). The remaining findings are mostly gaps around the change rather than in it: the operator cannot set the new knobs, so the documented upgrade remedy is unavailable there (row 2); the sizing formula and the 24h default-config lockout the issue thread asked to document are missing (rows 4 and 5); two wiring links, the startup backfill call and the registration_rate_limit plumbing, have no test (rows 6 and 7); and the memory store's CIMD rows lose their only bound with no linked follow-up (row 3).

The integration-tagged Redis tests have not been run, per the PR description, and they use unrestricted users, so they would not have caught row 1 either. They should pass in CI before merge.

PR description

The Summary and Changes table say there are "Lua scripts for capped register, renew, replace and delete" and that "RenewClientTTL, the replace path, and delete all keep the index in step". In the diff only register, renew and count are Lua scripts; the replace path (UpsertDCRIssuedClient) and ReconcileConfiguredClient are WATCH/MULTI transactions, and RedisStorage has no client-delete path, so that requirement is vacuous rather than implemented. Correcting the wording avoids reviewers hunting for scripts and a delete path that are not there. The PR also links the CIMD-bounding and operator-CRD follow-ups only in prose; tracking issues would make those accepted trade-offs checkable.

Documentation

  • docs/arch/11-auth-server-storage.md:308 and :322: sizing formula, default-config lockout, 30-day promotion and drain caveats, and upgrade notes (rows 4 and 5).
  • docs/redis-storage.md:255,263 and examples/operator/redis-storage/*.yaml: ACL needs +@fast or +time (row 1); :334: hash-tag and eviction requirements (row 16).
  • docs/observability.md: add the two new auth-server metrics (row 17).

Generated with Claude Code

Comment thread pkg/authserver/storage/redis.go Resolved
Comment thread pkg/authserver/storage/config.go Resolved
Comment thread pkg/authserver/storage/memory.go Resolved
Comment thread docs/arch/11-auth-server-storage.md Resolved
Comment thread docs/arch/11-auth-server-storage.md Resolved
Comment thread pkg/authserver/storage/memory.go Resolved
Comment thread pkg/authserver/storage/memory.go Resolved
Comment thread docs/redis-storage.md Resolved
Comment thread docs/arch/11-auth-server-storage.md Resolved
tgrunnagle and others added 2 commits October 19, 2026 11:16
Redis TIME is in the @fast ACL category, which the documented
auth-server ACL does not grant, so ACL-restricted deployments would
fail startup and every registration after upgrading. Index scores now
come from the replica clock, passed to the scripts as an argument.

Addresses #6863 review comments:
- HIGH pkg/authserver/storage/redis.go (4317000776): no TIME calls outside the documented ACL
- LOW pkg/authserver/storage/redis.go (4317000835): UpsertDCRIssuedClient refuses counted clients
- LOW pkg/authserver/storage/redis.go (4317000843): check register script reply types
- LOW pkg/authserver/storage/redis.go (4317000882): report an existing ID before the cap
- LOW pkg/authserver/storage/redis_dcr_cap_test.go (4317000904): pin index/row hash-slot layout
- LOW pkg/authserver/storage/memory.go (4317000933): reject negative cap options
- LOW docs/redis-storage.md (4317000942): document hash tag and eviction requirements

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The in-memory store lost its only bound on CIMD rows when they left the
DCR cap, so they get a separate cap. The limiter now rejects NaN and
rate.Inf, and new tests pin that startup runs the index backfill and
that registration_rate_limit reaches /oauth/register.

Addresses #6863 review comments:
- MEDIUM pkg/authserver/storage/memory.go (4317000790): cap in-memory CIMD rows
- MEDIUM pkg/authserver/server_impl.go (4317000814): test that startup runs the backfill
- MEDIUM pkg/authserver/runner/embeddedauthserver.go (4317000827): test registration_rate_limit wiring
- LOW pkg/authserver/server/handlers/handler.go (4317000894): reject NaN/Inf rates, fix Retry-After doc
- LOW pkg/authserver/server/handlers/dcr_cap_test.go (4317000916): clock-tolerant Retry-After, gauge via decorator
- LOW pkg/authserver/storage/memory.go (4317000926): bounded scans, one clock read, explicit put helpers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses #6863 review comments:
- MEDIUM pkg/authserver/storage/config.go (4317000784): state the settings are RunConfig-only, track CRD fields in #6864
- MEDIUM docs/arch/11-auth-server-storage.md (4317000799): document sizing formula and default-config lockout
- MEDIUM docs/arch/11-auth-server-storage.md (4317000803): add upgrade notes, mirrored in docs/redis-storage.md
- LOW docs/arch/11-auth-server-storage.md (4317000956): add the DCR metrics to docs/observability.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tgrunnagle

Copy link
Copy Markdown
Collaborator Author

Review row 18 (PR description): fixed. The body now describes the Redis paths as they are. Only register and renew are Lua scripts. ReconcileConfiguredClient maintains the index inside its WATCH/MULTI transaction. UpsertDCRIssuedClient refuses counted clients. There's no client-delete path. The operator CRD follow-up is tracked in #6864, and the in-memory CIMD bound is now implemented in d98fe26 rather than left as a follow-up.

@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Oct 19, 2026
@tgrunnagle

Copy link
Copy Markdown
Collaborator Author

I checked the follow-up commits (5fb2bf9, d98fe26, b25181b) against each finding in the earlier review and resolved all 17 threads. Highlights:

  • TIME / ACL: the scripts now take now as an argument, and the commands they run (EXISTS, SET, ZADD, ZREMRANGEBYSCORE, ZCARD, ZRANGE, ZCOUNT, PEXPIRE) fall within the documented ACL categories, so no ACL change is needed.
  • CIMD rows on the memory store have their own cap. The decorator keeps resolving the client from its cache when a row is refused.
  • Redis register script: it checks for an existing row before the cap, matching the memory store.
  • New tests cover the startup backfill, registration_rate_limit reaching the endpoint, the index/row hash-tag layout, NaN/Inf limiter rejection, the CIMD cap, and the decorator-wrapped gauge.
  • Docs: the sizing formula, the upgrade notes, and the observability and Redis deployment sections are in place and match the code.

Still open before merge:

  1. CI: Go SDK / Verify generated Go SDK fails. sdk/go/client/client_test.go:225 asserts 202 schemas. The PR adds one (AuthenticationRegistrationRateLimitRunConfig), so the count should be 203.
  2. CI: Codespell fails. It flags unparseable (suggesting unparsable) at pkg/authserver/storage/redis_migrate.go:418 and :489.
  3. The current Redis scripts have not run against a real Redis. The only manual redis-cli check predates the TIME removal and the EXISTS-before-cap change, so today only miniredis covers them. The Redis integration tests (redis_dcr_cap_integration_test.go) run via task test-integration. I couldn't find a workflow that runs that task (the integration jobs under .github are operator-only), so they may only run if someone runs them by hand. Please run the DCR cap integration tests on a machine with working Docker and note the result here.

The lint, unit-test, swagger and codegen checks were still running when I looked, so I haven't seen their results.

Generated with Claude Code

Addresses #6863 review comments:
- MEDIUM sdk/go/client/client_test.go:225 (issuecomment-6274488433): count the new RegistrationRateLimitRunConfig schema (203)
- MEDIUM pkg/authserver/storage/redis_migrate.go:418,489 (issuecomment-6274488433): spell "unparsable" for codespell

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tgrunnagle

Copy link
Copy Markdown
Collaborator Author

Replying to #6863 (comment):

  1. SDK schema count: addressed in fae8ff0 (202 → 203).
  2. Codespell: addressed in fae8ff0. redis_migrate.go:418 and :489 now say "unparsable". I also renamed the matching test to TestRedisStorage_BackfillDCRClientIndex_SkipsUnparsableRows.
  3. Current scripts against real Redis: I ran them locally against redis:7-alpine (Sentinel via testcontainers):
    go test -race -tags integration -count=1 -run 'TestIntegration_DCRCap' -v ./pkg/authserver/storage/
    --- PASS: TestIntegration_DCRCap
        --- PASS: .../TTL_and_index_score_follow_registration_and_first_use
        --- PASS: .../rejects_at_the_cap_without_writing_a_row
        --- PASS: .../concurrent_replicas_never_exceed_the_cap
        --- PASS: .../backfill_indexes_rows_written_before_the_index
    
    I ran a targeted go test instead of task test-integration because the task runs ./.... One other test in the package fails, TestIntegration_ClientAssertionJWT/known_JTI_is_invalid ("assertion lifetime exceeds maximum of 5m0s"), and it has nothing to do with this PR: the test sets a 1h assertion lifetime, and Add private_key_jwt DCR client authentication #6427 added the 5m cap. The PR body's test plan was also wrong to say the integration tests cover slot release on expiry. Only the miniredis unit tests cover it, and I've corrected the body.

Generated with Claude Code

@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Oct 19, 2026
@tgrunnagle
tgrunnagle marked this pull request as ready for review October 19, 2026 18:43
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Oct 19, 2026

@jhrozek jhrozek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A few operational notes on the cap, the backfill and the upgrade path. None of them block this from my side.

Comment thread pkg/authserver/storage/redis_migrate.go Resolved
Comment thread pkg/authserver/storage/config.go Resolved
Comment thread pkg/authserver/server/handlers/dcr.go Resolved
Comment thread pkg/authserver/server_impl.go Resolved
tgrunnagle and others added 2 commits October 19, 2026 14:13
A failed backfill only leaves the cap undercounting older rows, which
is how the server behaved before the cap, so it no longer stops
startup. The backfill now logs what it did and warns when existing
clients already fill the cap, so operators can see why registrations
fail from the first request.

Addresses #6863 review comments:
- MEDIUM pkg/authserver/server_impl.go (4318839163): log a backfill failure at WARN and continue; log rows indexed and duration
- MEDIUM pkg/authserver/storage/redis_migrate.go (4318839143): WARN once at startup when the count is at or above max_dcr_clients

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cap rejects without evicting, and without telemetry the first sign
was users getting a 503. Rejections now log one WARN at most every 15
minutes with the cap, the live count, Retry-After and how many
rejections were held back, so the volume is bounded by time rather than
by the unauthenticated caller. ClientCapacityError carries the cap that
was reached for that line.

Addresses #6863 review comments:
- MEDIUM pkg/authserver/server/handlers/dcr.go (4318839156): rate-limited WARN on cap rejection; alerting guidance in docs/observability.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Oct 19, 2026
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Oct 19, 2026
@tgrunnagle
tgrunnagle added this pull request to stack #6872 October 20, 2026 01:41
jhrozek
jhrozek previously approved these changes Oct 20, 2026

@jhrozek jhrozek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the quick turnaround on the operational notes. The cap, the backfill and the upgrade path all look good to me.

Resolve conflicts with the consent-exempt clients change (#6882) by
keeping both sides: Config.Validate runs both the client admission and
the issuer/consent checks, and buildHandlerOptions wires both the
register rate limit and the consent-exempt client IDs.

NewHandler moves the consent-exemption setup into a helper to stay
under the gocyclo limit, and runs it before registering the DCR
metrics so a validation failure cannot leave a gauge callback behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Oct 20, 2026
tgrunnagle added a commit that referenced this pull request Oct 20, 2026
Operator-managed deployments, the main multi-replica Redis audience,
could not set the DCR client cap or the /oauth/register rate limit
added in #6863, so a Redis deployment already over the default cap had
no way to raise it before upgrading.

Implements changes for issue #6864:
- Add storage.maxDCRClients (minimum 0) and registrationRateLimit to
  the embedded auth server config. The rate limit reuses the CRD's
  RateLimitBucket (maxTokens, refillPeriod) rather than a float field.
- Convert both in BuildAuthServerRunConfig. A memory storage config is
  now emitted when it sets a cap; a non-positive refillPeriod is
  rejected as an invalid config.
- Regenerate CRDs, API docs and the swagger/SDK descriptions, and drop
  the "not yet settable through the operator" notes from the docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tgrunnagle
tgrunnagle merged commit 0bf3ed1 into main Oct 20, 2026
50 of 66 checks passed
@tgrunnagle
tgrunnagle deleted the chambray-amount branch October 20, 2026 13:06
tgrunnagle added a commit that referenced this pull request Oct 20, 2026
* Expose DCR client cap and register rate in CRD

Operator-managed deployments, the main multi-replica Redis audience,
could not set the DCR client cap or the /oauth/register rate limit
added in #6863, so a Redis deployment already over the default cap had
no way to raise it before upgrading.

Implements changes for issue #6864:
- Add storage.maxDCRClients (minimum 0) and registrationRateLimit to
  the embedded auth server config. The rate limit reuses the CRD's
  RateLimitBucket (maxTokens, refillPeriod) rather than a float field.
- Convert both in BuildAuthServerRunConfig. A memory storage config is
  now emitted when it sets a cap; a non-positive refillPeriod is
  rejected as an invalid config.
- Regenerate CRDs, API docs and the swagger/SDK descriptions, and drop
  the "not yet settable through the operator" notes from the docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Validate registration refill period at admission

A zero or negative refillPeriod was only caught at reconcile time. A
CEL rule on registrationRateLimit now rejects it when the resource is
created, and the Go check stays as a backstop for objects admitted
before the rule existed. The rule sits on the field rather than the
shared RateLimitBucket type, so MCPServer rate limits are unchanged.

Envtest cases cover the new admission rules for both fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name CRD fields in registration rate errors

Addresses #6867 review comments:
- LOW cmd/thv-operator/pkg/controllerutil/authserver.go (4319701380): describe the refillPeriod check as defense in depth
- LOW cmd/thv-operator/pkg/controllerutil/authserver.go (4319701407): check maxTokens and wrap RunConfig validation errors with the CRD field name
- LOW cmd/thv-operator/test-integration/mcp-external-auth/dcr_cap_validation_test.go (4319701411): cover VirtualMCPServer admission and vMCP conversion
- LOW cmd/thv-operator/pkg/controllerutil/authserver_dcr_cap_test.go (4319701415): use the package's k8sptr alias

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document DCR cap upgrade order and rate examples

Addresses #6867 review comments:
- MEDIUM docs/arch/11-auth-server-storage.md (4319701371): spell out the operator-crds, field, operator upgrade order and drop upgrade advice from the CRD field text
- LOW cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go (4319701419): state that maxDCRClients 0 removes all protection and is meant to be temporary
- LOW cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go (4319701422): add a worked rate/burst example and a sample manifest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot mentioned this pull request Oct 22, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

authserver: cap DCR-registered clients on Redis storage, expire unused clients early, make the register rate configurable

2 participants