fix: OHE-3276 make managed LLM key rotation concurrency-safe (enterprise#439) - #440
Conversation
Overlapping managed-key rotations on conversation start could interleave delete_key_by_alias + generate under the shared per-org alias, orphaning a key a concurrent rotation had just minted and handed to a sandbox. The sandbox's first LLM call then failed with a non-retryable 401 (token_not_found_in_db). rotate_managed_llm_key now: - serializes per org via a pg_advisory_xact_lock (no interleaving), - re-reads the member key under the lock and, when given only_if_current, reuses a key a concurrent rotation already minted (idempotent refresh), - generates-before-delete and deletes only the *specific* previous key, never delete_key_by_alias. Both refresh callers (live-status conversation start and settings write) pass only_if_current so overlapping refreshes are idempotent. Old-key cleanup is centralized in the store; the refresh route no longer double -deletes. Co-authored-by: openhands <openhands@all-hands.dev>
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Live validation on a KinD/Helm install surfaced a defect in the initial
concurrency fix: minting the replacement key before deleting the old one
("generate-before-delete") fails against a real LiteLLM proxy, which enforces
unique key aliases (400 "Key with alias '...' already exists. Unique key
aliases across all keys are required."). The mocked unit tests did not enforce
alias uniqueness, so they passed.
The actual #439 fix is the per-org advisory lock (serializes rotations) plus
only_if_current (an overlapping refresh reuses the key a concurrent rotation
already minted instead of rotating/deleting a key just handed to a sandbox).
Given serialization, deleting the specific previous key before generating the
replacement under the same alias is safe and satisfies the unique-alias
constraint. Reverted generate-before-delete accordingly; cleanup happens inside
the lock before generate (route still owns no double-delete).
Added a delete-before-generate ordering assertion to guard the regression the
mocks missed.
Validated live: concurrent overlapping refreshes converge on one valid key (no
token_not_found_in_db); the legacy delete_key_by_alias sequence still
reproduces the orphan on the same cluster.
Co-authored-by: openhands <openhands@all-hands.dev>
…ate) Live end-to-end validation on the KinD/Helm install exposed a latent, high- severity failure mode in the previous delete-specific approach: if the DB member key and the LiteLLM alias-holder ever diverge (e.g. a rotation mints the new key in LiteLLM but its DB commit then fails), delete_key(old_key) never frees the shared alias, so every subsequent rotation for that org gets permanently stuck on a unique-alias 400 -> every conversation start silently receives a stale key and the sandbox's first LLM call 401s. Switch the rotation to clear the shared alias with delete_key_by_alias *under the per-org advisory lock*, then mint. This is safe: the #439 root cause was this by-alias delete running UNSERIALIZED; the advisory lock now serializes rotations and only_if_current stops an overlapping refresh from deleting a key already handed to a sandbox. Clearing by alias also self-heals a divergent alias. Tests updated to assert alias-clear-before-generate ordering and that the losing concurrent refreshers reuse the winner's key (exactly one mint + one alias clear for N overlapping stale-key refreshes). Route delete-failure test now exercises a best-effort alias-clear failure. Co-authored-by: openhands <openhands@all-hands.dev>
Live verification on a Replicated VM ✅Reproduced #439 on a control build and confirmed this PR resolves it, on a self-hosted Builds
1. Server-side collision —
|
| Build | rotations OK | key/generate 400 |
surviving tokens |
|---|---|---|---|
Control 1.61.0 |
1 / 4 (3 exceptions) | 3 | 1 |
sha-4e765a2 (×5 runs) |
4 / 4 every run | 0 | 1 |
2. Client-side cache 401 — the #439 symptom, reproduced end-to-end
Minted a managed key K0, verified a completion works, then fired two overlapping refreshes and
had a "sandbox" call the proxy with the key the first refresh handed out. The only difference between
the two rows is the only_if_current argument the refresh path passes
(live_status_app_conversation_service._maybe_refresh_managed_llm_key: this PR's L1530 vs 1.61.0's L1525):
buggy caller rotate_managed_llm_key() → refreshA/refreshB keys DIFFER (B deleted A's key)
sandbox completion(keyA) → HTTP 401
"Invalid proxy server token passed... Unable to find token in cache or
`LiteLLM_VerificationTokenTable`" # == #439 token_not_found_in_db, verbatim
fixed caller rotate_managed_llm_key(only_if_current=K0) → refreshA/refreshB keys IDENTICAL (B skipped, reused)
sandbox completion(keyA) → HTTP 200
3. Why #439 is "intermittent" yet can hit on every conversation
-
A single isolated conversation start fired exactly one rotation
(stale_key_detected → rotate_managed_llm_key:rotated, ~85 ms), noskipped_concurrent— it does
not fail on its own. Confirms it is a genuine race. -
Overlapping starts (all seeing the same stale key) are where it breaks:
caller keys minted for 4 overlapping starts result buggy ( 1.61.0)4 distinct 3 of 4 keys orphaned → those sandboxes 401 fixed (this PR) 1 (3 idempotent skips) 0 orphaned → 0 failures N overlapping starts orphan N−1 keys on the old path. Because verify-then-rotate runs from several
places per start (the live-status path at two call sites + the settings path, all on the same shared
per-org alias) and the stale-key check fires on ~every start, the overlap is effectively structural —
which is how an "intermittent" race presents as failing on nearly every conversation.
Key finding
The advisory lock alone does not fix the 401: running the buggy caller (only_if_current=None) on the
fix image (lock active) still minted 4 distinct keys and orphaned 3. The only_if_current idempotency
guard is the part that prevents deleting a key already handed to a sandbox. This PR ships both, and they
address two distinct faces of the same race:
| symptom | fixed by |
|---|---|
already exists 400 on /key/generate |
pg_advisory_xact_lock (serializes rotations) |
token_not_found_in_db 401 at completion (#439) |
only_if_current (idempotent refresh, no orphaning) |
Verdict: #439 reproduced on 1.61.0; both faces gone on sha-4e765a2 across all runs.
Drafted by an AI agent (OpenHands) on behalf of the tester.
Mutation review of the tests in this PRI mutation-tested the suites this PR touches: break a behaviour in the source on Baseline —
Caught — all 5 controls, plus 6 of the 9 candidates ❌
The central claim of the PR is pinned, and pinned by behaviour rather than by
Survivors — 3 gaps ✅
M5 — a member with no stored key returns
|
Add coverage for the three surviving mutants from the mutation review on PR #440: - M5: rotate(only_if_current=...) must still mint a real key for a member whose stored key is unset (read back as old_key is None) rather than short-circuiting to ROTATED with new_key=None. - M6/M8/M7: _org_rotation_advisory_lock_key must be stable, per-org, and fit a signed 64-bit bigint (full-width digest), pinning per-org lock serialization directly instead of only via a driver error. Co-authored-by: openhands <openhands@all-hands.dev>
|
Thanks for the mutation review — really useful framing. I've closed the three surviving mutants in
All 64 tests in On the not-a-test-gap note about This comment was created by an AI agent (OpenHands) on behalf of the user. |
There was a problem hiding this comment.
Note: submitted as a COMMENT review rather than APPROVE because GitHub does not allow approving a pull request authored by the same account. The verdict below is nonetheless approve.
Code Review
🟢 Good — a focused, well-reasoned concurrency fix for the #439 token_not_found_in_db 401. I traced the full flow (_maybe_refresh_managed_llm_key / _maybe_rotate_stale_managed_key → rotate_managed_llm_key → LiteLlmManager delete/generate) against the PR head (fa7984b) and found no blocking issues. The serialization is provably correct, the idempotent re-read is the right shape, and the concurrency test is a genuine regression guard against real Postgres.
[CRITICAL ISSUES]
None.
[WHAT WAS VERIFIED]
-
[
storage/saas_settings_store.py, L1141-1149] Serialization is correct and leak-free:pg_advisory_xact_lockis acquired inside the session's transaction and auto-releases on commit/rollback. Every early-return path (MISSING_MEMBER,BYOK, theskipped_concurrentidempotency branch) exits theasync with a_session_maker()block, so the lock is always released, and no intermediate commit precedes the mint+persist — the critical section (re-read → delete-by-alias → generate → persist) is fully covered by one lock hold. -
[
storage/saas_settings_store.py, L1150-1158] The re-read under the lock is load-bearing:session.expire_all()+ re-select(User)under READ COMMITTED makes a concurrent winner's committed key visible, so a loser seesold_key != only_if_currentand reuses the fresh key instead of rotating (and orphaning) it. Using the pre-lock value here would have reintroduced the race — the code correctly reads after acquiring the lock. -
[
storage/saas_settings_store.py, L1177-1194] Theonly_if_currentempty-key edge is handled: theold_key is not Noneguard mints a real key for a member with no stored key (_llm_api_key = '') rather than short-circuiting tonew_key=None. Pinned bytest_rotate_only_if_current_mints_when_member_has_no_key. -
[
server/routes/api_keys.py, L494-499] Removing the route-leveldelete_key(old_key)is correct: delete-then-generate ordering is mandatory (LiteLLM enforces unique aliases) and is now safe under the per-org lock; the by-alias clear already removes the previous key, so the route's extra best-effort delete was redundant.
[TESTING GAPS]
None blocking. Credit where due: test_concurrent_rotations_serialize_without_orphaning_survivor runs 4 real concurrent rotate_managed_llm_key() calls against a real Postgres DB (postgres_testdb, NullPool → distinct connections, so the advisory lock actually serializes across connections) and asserts exactly one generate_key + one delete_key_by_alias with all callers converging on one key. It fails on the old unserialized code and passes on the new — exactly the right shape, not mock-wiring. The lock-key helper test also pins per-org distinctness and signed 64-bit bigint fit.
[IMPROVEMENT OPPORTUNITIES]
- [
storage/saas_settings_store.py, L1195-1207] Divergent-alias cleanup (non-blocking): in the edge case of a prior key minted under a non-canonical alias, dropping the route's specific-key delete leaves that stray key uncleaned. This only leaks an inertLiteLLM_VerificationTokenrow — no functional impact, and not the #439 orphan-causing-401, since the by-alias clear self-heals the canonical alias. Worth a follow-up at most; no action needed for this PR.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟡 MEDIUM
Touches managed-key rotation on the LLM completion/auth boundary and adds DB-level locking, so it is not trivially LOW. But the change is well-contained, additive, backward-compatible (only_if_current defaults to the prior forced-rotation behaviour), has no schema change and no API contract change, and is backed by real-Postgres concurrency tests plus a documented live reproduction. All current-head CI is green, including Python Tests on Linux (3.12). No human-only gate applies.
VERDICT:
✅ Approve — correct fix, right scope, real regression coverage.
KEY INSIGHT:
The fix pairs a per-org advisory lock (serializes rotations) with an idempotent only_if_current re-read (serialized-but-late refreshes reuse rather than re-rotate). Together they close both the "delete wipes a concurrently-minted key" race and the "second start rotates a key already handed to a sandbox" race — without a grace period that the LiteLLM unique-alias constraint would forbid.
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.- Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
This review was generated by an AI agent (OpenHands) on behalf of the requesting user.
ak684
left a comment
There was a problem hiding this comment.
Reviewed the advisory-lock scope, refreshed read under the lock, and only_if_current callers. The compare-after-lock path makes overlapping stale-key refreshes reuse the winner instead of deleting its replacement. Focused API-key, settings-rotation, and conversation-service tests: 276 passed. No blocking code findings at this head.
GitHub currently reports merge conflicts. Resolve those against main and rerun these checks before merging; this approval applies to the reviewed commit, not an unreviewed conflict resolution.
|
Resolved the conflicts with current main in 4f31fc2. Preserved main’s stale organization-key repair and this PR’s conditional/concurrent rotation logic. Validation: 331 tests passed across managed-key rotation, live conversation status, API-key routes, and the settings store. Required pre-commit checks passed. The published Git tree matches the tested tree. CI is rerunning on the new commit. |
ak684
left a comment
There was a problem hiding this comment.
Re-reviewed the conflict resolution at this head. Both main’s stale managed-key cleanup and this PR’s compare-after-lock rotation guard are preserved. 331 focused key-rotation, conversation-service, API-key and settings-store tests passed; required pre-commit passed. No blocking code findings. Merge once the remaining Python CI job passes.
|
🚀 Released in 1.64.0. |
HUMAN:
AGENT:
Why
Fixes #439. After upgrading enterprise-server to 1.60.0, conversations
intermittently died on the sandbox's first LLM call with a non-retryable
401
AuthenticationError(Invalid proxy server token... token_not_found_in_db)even though a valid managed key existed and worked for server-side calls.
Root cause (verified by live reproduction): the managed-key refresh runs on
every conversation start, and
rotate_managed_llm_key()diddelete_key_by_alias(<shared per-org alias>)thengenerate_key(...)with noserialization. Two overlapping rotations for the same org could interleave so
that one rotation's
delete_key_by_aliaswiped the key the other rotation hadjust minted and handed to a sandbox. That sandbox's key was then absent from
LiteLLM's
LiteLLM_VerificationTokentable -> 401token_not_found_in_db.Summary
rotate_managed_llm_key()now serializes per org with apg_advisory_xact_lock, so rotations for the same org cannot interleave.only_if_current,reuses a key a concurrent rotation already minted instead of rotating again
(idempotent stale-key refresh). This stops overlapping conversation starts
from orphaning each other's keys.
delete_key_by_aliasand thenmints the replacement. The by-alias clear is not the Managed-key rotation race on conversation start → intermittent token_not_found_in_db 401 (enterprise-server 1.60.0 regression) #439 bug once it runs
under the lock: serialization means it can no longer race a concurrent
rotation's freshly-minted key. Clearing by alias (rather than only the
specific previous key) also self-heals a divergent alias (e.g. after a
rotation whose LiteLLM key was minted but whose DB commit failed), which would
otherwise wedge every future rotation for that org on a unique-alias 400.
_maybe_refresh_managed_llm_key) verifies themember key and, when stale, calls
rotate(only_if_current=<observed key>).Issue Number
Fixes #439.
How to Test
Unit tests:
Live validation was run on a KinD + Helm install against the built image
(
ghcr.io/openhands/enterprise-server:sha-435b397) and a real LiteLLM proxy.Three layers, all green:
_maybe_refresh_managed_llm_key+ real/chat/completions):with the old unserialized rotate, an overlapping start orphans a sandbox's key
and its real inference call returns 401
token_not_found; with the fix, twoconcurrent starts converge on one fresh key and both real calls authenticate.
A divergence scenario (stray key under the alias + stale DB key) self-heals
with the fix (it would wedge the delete-specific variant forever).
disabled (unique lock key per call), 6 concurrent refreshes error on
unique-alias 400s and orphan a key; with the lock enabled they all converge on
one valid key. A cross-pod run (3 replicas, separate processes / Postgres
connections) confirms
pg_advisory_xact_lockserializes across connections.with 0 orphans, 0 errors; every race converges on one valid key.
Type
Notes
No schema changes. The advisory lock is a per-org transaction lock keyed by the
org id; it only serializes managed-key rotations for the same org.
Enterprise server image for this PR: