Skip to content

5/5 fix: harden request-time account rotation#73

Open
ndycode wants to merge 10 commits intomainfrom
fix/account-rotation-traversal
Open

5/5 fix: harden request-time account rotation#73
ndycode wants to merge 10 commits intomainfrom
fix/account-rotation-traversal

Conversation

@ndycode
Copy link
Owner

@ndycode ndycode commented Mar 8, 2026

Summary

  • harden request-time account rotation with a dedicated request selector that excludes already-attempted accounts and refuses to reuse sticky current-account selections
  • switch request-attempt bookkeeping from mutable array indices and mutable auth fields to a stable per-account immutable key so removals, token refresh, and account-id hydration cannot corrupt an in-flight retry cycle
  • continue traversal when a selected account loses its last local token before request execution instead of aborting the request loop
  • compute pool wait time with per-account blocker maxima, preserve disabled-refill waits safely, clear index-keyed tracker state after renumbering, and remap toast debounce state when an account is removed
  • expand regression coverage for network, 5xx, empty-response, local-bucket, attempted-key, zero-refill, and Windows-oriented concurrency/redaction paths

Root cause

The fetch loop tracked attempted accounts, but the general hybrid selector could still legally return the current or fallback account. That contract mismatch let traversal terminate early after a repeated selection instead of moving to another eligible account. The remaining edge cases were that retry bookkeeping still used mutable values (account.index, then later refresh/account fields), which could drift if the account pool was renumbered or the selected account refreshed mid-request.

Final branch tip

  • branch: fix/account-rotation-traversal
  • latest commit: 9d6fdd0

Validation

  • npm run lint
  • npm run typecheck
  • npm test
  • npm run build

Regression coverage

  • request-time local bucket misses no longer double-penalize health: index.ts:2260
  • request-time local bucket miss still rotates after the async selection/consume window and asserts recordRateLimit is not called: test/index.test.ts:2192
  • request-attempt bookkeeping now uses a stable immutable per-account key instead of mutable indices or mutable auth fields: lib/accounts.ts:176, lib/accounts.ts:219, lib/accounts.ts:662, index.ts:2087
  • mixed blocker wait computation is per-account max(...) then pool min(...): lib/accounts.ts:933
  • index-keyed tracker state and toast debounce state are cleared/remapped when account removal renumbers the pool: lib/accounts.ts:955
  • wait-state tests reset shared trackers and cover local recovery, mixed blockers, and zero-refill fallback: test/accounts.test.ts:896
  • token wait helper directly covers untouched, drained, and zero-refill buckets: test/rotation.test.ts:186
  • request wait-path test asserts the computed 12s delay: test/index.test.ts:2289
  • default request-selector mock honors attempted keys: test/index.test.ts:304
  • retry mock honors attempted keys: test/index-retry.test.ts:58
  • request-selector integration covers attempted-key skipping plus token and health tracker reset after renumbering: test/rotation-integration.test.ts:159, test/rotation-integration.test.ts:171

Windows concurrency and token redaction coverage

  • concurrent save/file-locking coverage: test/rotation-integration.test.ts:274, test/storage-async.test.ts:271
  • request log payload redaction coverage: test/logger.test.ts:744
  • raw payload logging remains opt-in only: test/logger.test.ts:764

Live check

  • branch build loaded in OpenCode via local plugin path
  • command run: opencode run "Reply with READY only." --model=openai/gpt-5-codex --variant medium --agent livecheck
  • received READY
  • controlled project-account-state check was performed and the original account-store file was restored immediately after verification

Review status

  • Greptile summary concerns around wait semantics, request-time rotation, request-attempt drift, and coverage have been addressed in code and tests
  • CodeRabbit findings around local bucket handling, computed wait assertion, attempted-key mocks, index-renumbered tracker leakage, toast debounce drift, and immutable request-attempt identity have been addressed on the branch tip

note: greptile review for oc-chatgpt-multi-auth. cite files like lib/foo.ts:123. confirm regression tests + windows concurrency/token redaction coverage.

Greptile Summary

this pr hardens the request-time account rotation loop by replacing the mutable-index attempted set with a stable per-account immutableId key, introducing a dedicated getNextRequestEligibleForFamilyHybrid selector that filters attempted accounts before selection (not after), fixing a breakcontinue that was silently aborting traversal on local token bucket misses, and computing pool wait time as min(per-account max(...)) rather than a flat pool-wide min — which correctly surfaces the earliest-available account. tracker reindexing on account removal and toast-index remapping are also addressed. regression coverage is thorough across network errors, 5xx, empty-response, consumed-token races, indefinite-wait, and windows-oriented reindexing paths.

key changes:

  • attempted: Set<number>Set<string> keyed on account.immutableId, immune to index drift from removals or token refresh
  • getNextRequestEligibleForFamilyHybrid pre-filters attempted and ineligible accounts so the selector can never return a repeated or unavailable account
  • consumeToken miss on the async window now continues to the next account instead of breaking out of the loop; recordRateLimit is no longer called for local bucket misses (correct — only remote 429s earn that penalty)
  • getMinWaitTimeForFamily includes token-bucket recovery time and propagates Number.POSITIVE_INFINITY for zero-refill configs; index.ts guards this with hasFiniteWait to prevent sleeping indefinitely
  • reindexAfterRemoval on both HealthScoreTracker and TokenBucketTracker keeps per-account history correctly aligned after pool renumbering — directly addresses the windows antivirus-triggered removal scenario
  • immutableId is transient (regenerated per plugin load), which is safe because attempted is local to a single request cycle and never persisted

Confidence Score: 4/5

  • safe to merge — core rotation logic is correct and well-tested; one minor undocumented Infinity contract on getMinWaitTimeForFamily worth noting before merge
  • the three root-cause fixes (immutable attempt key, dedicated pre-filtering selector, break→continue) are each correct and independently tested. tracker reindexing closes the windows-removal data-corruption path. the only gaps are a misleading test description and an undocumented Infinity return contract on getMinWaitTimeForFamily that future callers could misuse with setTimeout. no data-loss or correctness regressions identified.
  • lib/accounts.ts — getMinWaitTimeForFamily can return Number.POSITIVE_INFINITY; callers must guard before passing to setTimeout or similar timer APIs

Important Files Changed

Filename Overview
lib/accounts.ts core accounting layer — adds immutableId, getNextRequestEligibleForFamilyHybrid, getAccountAvailabilitySnapshot, and tracker reindexing on removal; logic is sound but getMinWaitTimeForFamily can return Infinity with no contract doc for callers
lib/rotation.ts adds reindexAfterRemoval to both trackers and getWaitTimeUntilTokenAvailable to TokenBucketTracker; key-parsing logic for colon-delimited quota keys is correct and covers model-qualified keys
index.ts switches attempted to Set<string>, replaces the broken hybrid selector with getNextRequestEligibleForFamilyHybrid, fixes breakcontinue and removes erroneous recordRateLimit on local bucket miss, and guards infinite wait from triggering setTimeout
test/rotation.test.ts adds getWaitTimeUntilTokenAvailable and reindexAfterRemoval unit tests; one test description ("partially drained") is misleading — the drain call fully empties the bucket
test/accounts.test.ts adds beforeEach/afterEach tracker resets, new getMinWaitTimeForFamily tests (local recovery, mixed blockers, zero-refill), and three getNextRequestEligibleForFamilyHybrid unit tests covering skip, ineligible-current, and null-fallback paths
test/index.test.ts upgrades mock to expose getNextRequestEligibleForFamilyHybrid and getRequestAttemptKey; adds createRequestAwareManager helper and six new integration tests covering network error, 5xx, empty-response, consumeToken race, indefinite wait, and local bucket depletion rotation
test/index-retry.test.ts mock correctly adds getNextRequestEligibleForFamilyHybrid and getRequestAttemptKey that honor attempted-key semantics; aligns retry test mock with the new request-selector contract
test/rotation-integration.test.ts adds three integration tests: attempted-key skipping, token/health tracker reset after renumbering, and survivor index alignment after removal; covers the Windows-relevant reindex path end-to-end

Sequence Diagram

sequenceDiagram
    participant FetchLoop as fetch loop
    participant Selector as getNextRequestEligibleForFamilyHybrid
    participant Snapshot as getAccountAvailabilitySnapshot
    participant TokenTracker as TokenBucketTracker
    participant Manager as AccountManager

    FetchLoop->>Selector: call with family, model, attemptedKeys
    Selector->>Snapshot: check current account eligibility
    Snapshot->>TokenTracker: getTokens(account.index, quotaKey)
    Snapshot-->>Selector: eligible, tokensAvailable, rateLimited, coolingDown
    alt eligible and not in attemptedKeys
        Selector-->>FetchLoop: return current account
    else ineligible or already attempted
        Selector->>Snapshot: check each remaining candidate
        Selector->>Selector: selectHybridAccount on eligible subset
        Selector-->>FetchLoop: return selected account or null
    end
    FetchLoop->>Manager: getRequestAttemptKey returns immutableId
    FetchLoop->>Manager: consumeToken
    alt token consumed
        FetchLoop->>FetchLoop: execute fetch request
    else token lost to async race
        FetchLoop->>FetchLoop: continue to next account
    end
    FetchLoop->>Manager: getMinWaitTimeForFamily
    Manager->>TokenTracker: getWaitTimeUntilTokenAvailable per account
    Manager-->>FetchLoop: min of per-account max or Infinity
    alt hasFiniteWait and waitMs gt 0
        FetchLoop->>FetchLoop: sleep then retry
    else Infinity or zero
        FetchLoop-->>FetchLoop: surface indefinitely message or continue
    end
Loading

Fix All in Codex

Prompt To Fix All With AI
This is a comment left during a code review.
Path: test/rotation.test.ts
Line: 1201-1204

Comment:
**test description says "partially drained" but drains the full bucket**

`tracker.drain(0, undefined, DEFAULT_TOKEN_BUCKET_CONFIG.maxTokens)` drains exactly `maxTokens` tokens — that is a fully-empty bucket, not a partial drain. the assertion is correct (`> 0` is the right expectation for a drained bucket), but the description is misleading for anyone trying to understand what edge this test covers. consider either renaming the test to "fully drained" or capping the drain to, say, `maxTokens - 1` so the name and the state actually agree.

```suggestion
		it("returns a finite wait after the bucket is fully drained", () => {
			tracker.drain(0, undefined, DEFAULT_TOKEN_BUCKET_CONFIG.maxTokens);
			expect(tracker.getWaitTimeUntilTokenAvailable(0)).toBeGreaterThan(0);
		});
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: lib/accounts.ts
Line: 435-437

Comment:
**`Math.POSITIVE_INFINITY` can escape into `waitTimes` but is not guarded before `Math.min`**

when `tokenWaitMs` is `Number.POSITIVE_INFINITY` (zero-refill bucket) the code correctly pushes it:

```typescript
accountWaits.push(Number.isFinite(tokenWaitMs) ? tokenWaitMs : Number.POSITIVE_INFINITY);
```

and `Math.max(...accountWaits)``Infinity` lands in `waitTimes`. that is the intended code path. however, the final `Math.min(...waitTimes)` will return `Infinity` if every account contributed only an infinite wait, and that value propagates to callers (handled in `index.ts` via `hasFiniteWait`). the risk is a future caller of `getMinWaitTimeForFamily` that does not guard against `Infinity` (e.g., uses the result directly as a `setTimeout` delay). `setTimeout(fn, Infinity)` on windows never fires, silently stalling the retry loop.

worth adding a doc-comment on `getMinWaitTimeForFamily` calling out the `Infinity` contract explicitly, so future callers are warned:

```typescript
/**
 * Returns the minimum wait time (ms) before any enabled account is expected to
 * be available for `family`/`model`. Returns 0 if at least one account is
 * immediately eligible. Returns `Number.POSITIVE_INFINITY` when all accounts
 * are blocked by a zero-refill token bucket and no finite wait can be computed.
 * Callers MUST guard against `Infinity` before passing the result to `setTimeout`.
 */
getMinWaitTimeForFamily(...)
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: lib/accounts.ts
Line: 296-309

Comment:
**`immutableId` is regenerated on every plugin reload — no comment explains why this is safe**

`randomUUID()` is assigned at parse/load time, so every plugin restart gives each account a fresh `immutableId`. any `attempted` set carrying the previous IDs is immediately stale. this is fine because `attempted` is local to a single request traversal (created fresh inside the `while (true)` outer loop), but that invariant is non-obvious from reading the field alone.

a one-line comment here would prevent a future engineer from being confused about whether the ID needs to survive a restart:

```typescript
// immutableId is intentionally transient — it is stable for the lifetime of
// a single plugin instance (one retry cycle) and does not need to be persisted.
immutableId: randomUUID(),
```

on windows, antivirus-triggered plugin reloads can happen mid-session, making this assumption more likely to be tested in the wild.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 332c319

Greptile also left 1 inline comment on this PR.

Context used:

  • Rule used - What: Every code change must explain how it defend... (source)

ndycode and others added 2 commits March 8, 2026 18:16
Add a request-scoped selector so retries exclude attempted accounts, respect local token-bucket availability, and return retryable waits when local capacity is temporarily exhausted.

Co-authored-by: Codex <noreply@openai.com>
Compute per-account availability waits before taking the pool minimum, and tighten 5xx and empty-response tests to assert that retries advance to a different account.

Co-authored-by: Codex <noreply@openai.com>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 8, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Account rotation now uses an attempted-account-key aware selector (getNextRequestEligibleForFamilyHybrid) and AccountAvailabilitySnapshot for eligibility; TokenBucketTracker can report token-wait times. The rotation loop treats local token exhaustion as continue and only stops when no eligible account is returned.

Changes

Cohort / File(s) Summary
Account manager & availability
lib/accounts.ts
Adds immutableId, AccountAvailabilitySnapshot, getAccountAvailabilitySnapshot, getRequestAttemptKey; introduces getNextRequestEligibleForFamilyHybrid(...); refactors selection, explainability, min-wait, and tracker resets on removals.
Token bucket & selection options
lib/rotation.ts
Adds TokenBucketTracker.getWaitTimeUntilTokenAvailable(...) and exports RequestHybridSelectionOptions (attemptedAccountKeys?: ReadonlySet<string>, pidOffsetEnabled?: boolean).
Call site / rotation loop
index.ts
Replaces getCurrentOrNextForFamilyHybrid(...) calls with getNextRequestEligibleForFamilyHybrid(..., { attemptedAccountKeys, pidOffsetEnabled }); changes attempted-key type to Set<string>; inner-loop uses continue on local token exhaustion and breaks only when no account is returned.
Tests & mocks
test/..., test/index.test.ts, test/index-retry.test.ts, test/accounts.test.ts, test/rotation*.test.ts
Updates/adds tests and test mocks/factories to exercise new hybrid API and attempted-account-key behavior; adds TokenBucketTracker wait-time tests; mocks expose getNextRequestEligibleForFamilyHybrid and getRequestAttemptKey.
Misc & manifests
package.json, small edits in index.ts and test scaffolding`
Formatting/indentation tweaks and test scaffolding updates to support new APIs.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Retry as RetryLogic (index.ts)
    participant AM as AccountManager
    participant Snap as AvailabilitySnapshot
    participant TBT as TokenBucketTracker

    Client->>Retry: Request (model, attemptedAccountKeys)
    Retry->>AM: getNextRequestEligibleForFamilyHybrid(family, model, { attemptedAccountKeys, pidOffsetEnabled })
    AM->>Snap: evaluate candidate account
    Snap->>TBT: getWaitTimeUntilTokenAvailable(candidateIndex, quotaKey)
    TBT-->>Snap: waitTime / tokensAvailable
    Snap-->>AM: eligible / rateLimited / waitTime
    alt eligible account found
        AM-->>Retry: ManagedAccount
        Retry->>Client: Execute request with account
    else no eligible accounts
        AM-->>Retry: null
        Retry->>Client: Return rate-limited / retry info
    else token depleted for candidate
        Retry->>Retry: continue to next candidate (add key to attemptedAccountKeys)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I hopped through keys with careful pace,
Snapshots told me which accounts could race,
Tokens hummed a patient tune,
I skipped the drained and chose the soon,
A rabbit routing by moonlit grace.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'harden request-time account rotation' clearly summarizes the main change: strengthening the account rotation logic during request execution with immutable attempt keys and token eligibility checks.
Description check ✅ Passed Pull request provides comprehensive summary, root cause analysis, validation steps, and detailed regression coverage mapping with file references.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/account-rotation-traversal
📝 Coding Plan
  • Generate coding plan for human review comments

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 and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
index.ts (1)

2260-2270: ⚠️ Potential issue | 🟠 Major

Don’t treat a local bucket miss like a real 429.

consumeToken() has already proven the local bucket is empty. Calling recordRateLimit() here penalizes the account a second time by lowering health and draining the bucket again, so concurrent misses can keep pushing recovery out and bias hybrid selection away from an otherwise healthy account. This path should rotate without recording a server-side rate limit.

Suggested fix
 const tokenConsumed = accountManager.consumeToken(account, modelFamily, model);
 if (!tokenConsumed) {
-  accountManager.recordRateLimit(account, modelFamily, model);
   runtimeMetrics.accountRotations++;
   runtimeMetrics.lastError =
     `Local token bucket depleted for account ${account.index + 1} (${modelFamily}${model ? `:${model}` : ""})`;
   runtimeMetrics.lastErrorCategory = "rate-limit-local";
   logWarn(
     `Skipping account ${account.index + 1}: local token bucket depleted for ${modelFamily}${model ? `:${model}` : ""}`,
   );
   continue;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@index.ts` around lines 2260 - 2270, The local token-bucket miss path should
not record a server-side 429: in the branch where
accountManager.consumeToken(account, modelFamily, model) returns false, remove
the call to accountManager.recordRateLimit(account, modelFamily, model) so we
don't penalize health or drain tokens again; instead simply increment
runtimeMetrics.accountRotations, set runtimeMetrics.lastError and
lastErrorCategory ("rate-limit-local"), log the warning, and continue rotation
(preserving the existing logWarn and continue behavior).
test/index.test.ts (1)

2289-2313: ⚠️ Potential issue | 🟡 Minor

Assert the computed wait, not just the generic 429.

This test injects a 12_000 ms family wait, but never verifies that the handler actually surfaces that delay. It will still pass if getMinWaitTimeForFamily() is ignored and the code falls back to a generic “all accounts are rate-limited” response, which leaves the new pool-min wait logic unprotected.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/index.test.ts` around lines 2289 - 2313, The test currently verifies
only a generic 429 message but must assert the computed family wait (12_000 ms)
is surfaced; update the assertion after calling sdk.fetch (and before/after
checking response.status) to verify the handler includes the wait returned by
getMinWaitTimeForFamily — e.g., check response.headers.get("Retry-After") or
that await response.text() contains the "12000" (or equivalent seconds value)
returned by createRequestAwareManager/getMinWaitTimeForFamily so the new
pool-min wait logic is actually exercised.
🧹 Nitpick comments (1)
test/index-retry.test.ts (1)

58-60: Honor attemptedIndices in this mock.

Right now this stub behaves like the old selector, so a retry path that accidentally reuses a tried account can still look green in this suite.

Proposed mock adjustment
-		getNextRequestEligibleForFamilyHybrid() {
-			return this.getCurrentOrNextForFamily();
+		getNextRequestEligibleForFamilyHybrid(
+			_family?: unknown,
+			_model?: unknown,
+			options?: { attemptedIndices?: ReadonlySet<number> },
+		) {
+			if (options?.attemptedIndices?.has(0)) return null;
+			return this.getCurrentOrNextForFamily();
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/index-retry.test.ts` around lines 58 - 60, The mock's
getNextRequestEligibleForFamilyHybrid currently just delegates to
getCurrentOrNextForFamily and ignores the attemptedIndices tracking, so update
the mock implementation of getNextRequestEligibleForFamilyHybrid to consult the
mock's attemptedIndices set/array and skip any indices already present
(mirroring the real selector's retry behavior) before returning a candidate; if
no unattempted candidate exists return the same null/undefined path the real
getCurrentOrNextForFamily would. Ensure you reference the mock's
attemptedIndices and the methods getNextRequestEligibleForFamilyHybrid and
getCurrentOrNextForFamily when making the change so tests cannot reuse
already-tried accounts.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/accounts.test.ts`:
- Around line 959-1010: The tests calling getTokenTracker().drain(...) leak
process-global tracker state; wrap the describe("getMinWaitTimeForFamily", ...)
tests (or each test) with cleanup so token trackers are reset between tests:
call resetTrackers() (or otherwise reinitialize the global tracker) in a
beforeEach/afterEach surrounding the AccountManager-based tests (or add inline
resetTrackers() at the start/end of each test) to ensure getTokenTracker(),
AccountManager, and getMinWaitTimeForFamily() run with a clean tracker state and
avoid order-dependent failures.

In `@test/index.test.ts`:
- Around line 2529-2538: The mock wrapper getNextRequestEligibleForFamilyHybrid
currently returns null if the single candidate from
customManager.getCurrentOrNextForFamilyHybrid(family, currentModel) is in
attemptedIndices, which incorrectly stops traversal; change it to loop: obtain
attemptedIndices as before, then repeatedly call
customManager.getCurrentOrNextForFamilyHybrid(family, currentModel) and if
candidate is null return null, if candidate.index is in attemptedIndices
continue the loop (advancing the underlying cursor) until you find a candidate
whose index is not in attemptedIndices, then return that candidate; keep the
same parameter names (family, currentModel, options) and use attemptedIndices
and customManager for identification.

---

Outside diff comments:
In `@index.ts`:
- Around line 2260-2270: The local token-bucket miss path should not record a
server-side 429: in the branch where accountManager.consumeToken(account,
modelFamily, model) returns false, remove the call to
accountManager.recordRateLimit(account, modelFamily, model) so we don't penalize
health or drain tokens again; instead simply increment
runtimeMetrics.accountRotations, set runtimeMetrics.lastError and
lastErrorCategory ("rate-limit-local"), log the warning, and continue rotation
(preserving the existing logWarn and continue behavior).

In `@test/index.test.ts`:
- Around line 2289-2313: The test currently verifies only a generic 429 message
but must assert the computed family wait (12_000 ms) is surfaced; update the
assertion after calling sdk.fetch (and before/after checking response.status) to
verify the handler includes the wait returned by getMinWaitTimeForFamily — e.g.,
check response.headers.get("Retry-After") or that await response.text() contains
the "12000" (or equivalent seconds value) returned by
createRequestAwareManager/getMinWaitTimeForFamily so the new pool-min wait logic
is actually exercised.

---

Nitpick comments:
In `@test/index-retry.test.ts`:
- Around line 58-60: The mock's getNextRequestEligibleForFamilyHybrid currently
just delegates to getCurrentOrNextForFamily and ignores the attemptedIndices
tracking, so update the mock implementation of
getNextRequestEligibleForFamilyHybrid to consult the mock's attemptedIndices
set/array and skip any indices already present (mirroring the real selector's
retry behavior) before returning a candidate; if no unattempted candidate exists
return the same null/undefined path the real getCurrentOrNextForFamily would.
Ensure you reference the mock's attemptedIndices and the methods
getNextRequestEligibleForFamilyHybrid and getCurrentOrNextForFamily when making
the change so tests cannot reuse already-tried accounts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4116a0c8-1761-42af-92e5-95ff561e1dd4

📥 Commits

Reviewing files that changed from the base of the PR and between 435638c and c309a6e.

📒 Files selected for processing (7)
  • index.ts
  • lib/accounts.ts
  • lib/rotation.ts
  • test/accounts.test.ts
  • test/index-retry.test.ts
  • test/index.test.ts
  • test/rotation-integration.test.ts

Handle disabled token refill waits safely, stop double-penalizing local bucket misses, and tighten rotation mocks and wait assertions around the new request-scoped selector.

Co-authored-by: Codex <noreply@openai.com>
Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

No issues found across 8 files

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 8 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="test/accounts.test.ts">

<violation number="1" location="test/accounts.test.ts:1041">
P2: Mutating `tracker.config.tokensPerMinute = 0` leaks to all subsequent tests because `resetTrackers()` only clears bucket entries, not the config on the singleton instance. Restore the original value after the test to prevent silent cross-test contamination.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/accounts.ts`:
- Around line 423-435: getAccountAvailabilitySnapshot incorrectly uses the
mutable account.index as the token-bucket identity (via
tokenTracker.getTokens(account.index, quotaKey)), which causes bucket/wait state
to leak when removeAccount renumbers indices; change the tracker key to a stable
per-account identifier (e.g., account.id or another immutable unique field)
instead of account.index, and update all token-tracker usages (getTokens,
get/set wait time, etc.) and getTokenTracker initialization to use that stable
key; alternatively, if adding a stable id is not possible, ensure removeAccount
remaps or clears token-tracker state for shifted indices (adjusting
getMinWaitTimeForFamily and related functions accordingly) so state does not
follow the new occupant.

In `@test/index.test.ts`:
- Around line 304-306: The mock implementation of
getNextRequestEligibleForFamilyHybrid returns accounts[0] unconditionally and
ignores attemptedIndices, which can mask traversal/retry bugs; update the mock
(getNextRequestEligibleForFamilyHybrid) to consult attemptedIndices and return
null if index 0 is present in attemptedIndices (otherwise return accounts[0]),
so even the single-account default mocks the real contract's behavior of not
reusing already-attempted indices.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d8e5e2e0-47ae-42ac-a6eb-0aaa1d38f0ab

📥 Commits

Reviewing files that changed from the base of the PR and between c309a6e and b16dcd9.

📒 Files selected for processing (6)
  • index.ts
  • lib/accounts.ts
  • test/accounts.test.ts
  • test/index-retry.test.ts
  • test/index.test.ts
  • test/rotation.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • index.ts

Clear index-keyed tracker state when accounts are renumbered, harden the default request-selector mocks, and extend token wait coverage for the final review findings.

Co-authored-by: Codex <noreply@openai.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/accounts.ts (1)

949-956: ⚠️ Potential issue | 🟡 Minor

Remap toast debounce state when indices shift.

lastToastAccountIndex is also keyed by account.index. After splice() + reindexing, the new occupant of that index inherits the previous account's debounce window, so account-switch toasts can be suppressed for the wrong account.

Suggested fix
 		this.accounts.splice(idx, 1);
 		this.accounts.forEach((acc, index) => {
 			acc.index = index;
 		});
+		if (this.lastToastAccountIndex === idx) {
+			this.lastToastAccountIndex = -1;
+		} else if (this.lastToastAccountIndex > idx) {
+			this.lastToastAccountIndex -= 1;
+		}
 		// Trackers are keyed by account index, so any removal that renumbers accounts
 		// must clear in-memory tracker state to avoid leaking waits/health to new occupants.
 		resetTrackers();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/accounts.ts` around lines 949 - 956, After removing an account and
reindexing via this.accounts.splice(...) and the forEach that sets acc.index,
also update the debounce map lastToastAccountIndex so entries keyed by numeric
indices are shifted down to reflect the removed index (so new occupants don't
inherit another account's debounce). Implement this by iterating the existing
lastToastAccountIndex keys, for any key > removedIdx move its value to key-1
(delete the old key), delete the removedIdx entry, and then call resetTrackers()
as before; reference this.accounts.splice, acc.index reassigning,
lastToastAccountIndex, and resetTrackers() when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/accounts.ts`:
- Around line 662-723: The retry bookkeeping uses the mutable array index
(account.index) which breaks when removeAccount renumbers entries; switch
attemptedIndices to use a stable per-account key (e.g., account.id or a new
immutable property like account.uid) instead of account.index: update the
map/filter that builds accountsWithMetrics to test and return that stable key,
update where selections are marked as attempted to add the stable key, and
ensure selectHybridAccount is invoked/updated to accept and return that stable
key (then map back to the current array index to set
currentAccountIndexByFamily, cursorByFamily and account.lastUsed); also update
any early-path checks (the currentIndex fast-path) to compare attemptedIndices
against the stable key rather than account.index.

---

Outside diff comments:
In `@lib/accounts.ts`:
- Around line 949-956: After removing an account and reindexing via
this.accounts.splice(...) and the forEach that sets acc.index, also update the
debounce map lastToastAccountIndex so entries keyed by numeric indices are
shifted down to reflect the removed index (so new occupants don't inherit
another account's debounce). Implement this by iterating the existing
lastToastAccountIndex keys, for any key > removedIdx move its value to key-1
(delete the old key), delete the removedIdx entry, and then call resetTrackers()
as before; reference this.accounts.splice, acc.index reassigning,
lastToastAccountIndex, and resetTrackers() when making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: da1c572f-2d40-4c72-9361-73cbb3ed6851

📥 Commits

Reviewing files that changed from the base of the PR and between b16dcd9 and e5166e1.

📒 Files selected for processing (4)
  • lib/accounts.ts
  • test/accounts.test.ts
  • test/index.test.ts
  • test/rotation.test.ts

Switch request attempt bookkeeping to a stable per-account key, clear toast debounce state on renumbering, and add the remaining review-driven regression coverage.

Co-authored-by: Codex <noreply@openai.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/accounts.test.ts (1)

1036-1050: Avoid mutating internal tracker state in tests; pass configuration at construction instead.

The test directly mutates tracker.config.tokensPerMinute and relies on try/finally to restore it. Since getTokenTracker accepts an optional config parameter, use getTokenTracker({ tokensPerMinute: 0 }) to configure the tracker at construction, eliminating the need for internal state mutation and restoration.

🔧 Suggested approach
       const manager = new AccountManager(undefined, stored as never);
-      const tracker = getTokenTracker() as unknown as {
-        config: { tokensPerMinute: number };
-        drain: (accountIndex: number, quotaKey?: string, drainAmount?: number) => void;
-      };
-      const originalTokensPerMinute = tracker.config.tokensPerMinute;
-      try {
-        tracker.config.tokensPerMinute = 0;
-        tracker.drain(0, "codex", 50);
+      resetTrackers();
+      const tracker = getTokenTracker({ tokensPerMinute: 0 }) as unknown as {
+        drain: (accountIndex: number, quotaKey?: string, drainAmount?: number) => void;
+      };
+      tracker.drain(0, "codex", 50);

-        const waitTime = manager.getMinWaitTimeForFamily("codex");
-        expect(waitTime).toBe(Number.MAX_SAFE_INTEGER);
-      } finally {
-        tracker.config.tokensPerMinute = originalTokensPerMinute;
-      }
+      const waitTime = manager.getMinWaitTimeForFamily("codex");
+      expect(waitTime).toBe(Number.MAX_SAFE_INTEGER);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/accounts.test.ts` around lines 1036 - 1050, The test mutates internal
tracker state by setting tracker.config.tokensPerMinute directly; instead
construct the tracker with the desired config via getTokenTracker({
tokensPerMinute: 0 }) and use that instance for tracker.drain and assertions so
you don't need the try/finally restore. Update the test to create the tracker
with getTokenTracker({ tokensPerMinute: 0 }) and then call tracker.drain(0,
"codex", 50) and verify manager.getMinWaitTimeForFamily("codex") returns
Number.MAX_SAFE_INTEGER, referencing AccountManager, getTokenTracker,
tracker.drain and manager.getMinWaitTimeForFamily.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/accounts.ts`:
- Around line 219-225: The request-attempt key uses mutable auth fields causing
mismatches; change getRequestAttemptKey to use a stable per-account identifier
instead (add a new immutableId on ManagedAccount assigned once when the account
object is created/loaded, e.g., a UUID) and include that immutableId (plus
organizationId if desired) in the key instead of refreshToken/accountId; also
ensure all account construction/loading code sets immutableId when missing so
attemptedAccountKeys.has(getRequestAttemptKey(account)) continues to match even
after updateFromAuth() rotates tokens or accountId is hydrated.

---

Nitpick comments:
In `@test/accounts.test.ts`:
- Around line 1036-1050: The test mutates internal tracker state by setting
tracker.config.tokensPerMinute directly; instead construct the tracker with the
desired config via getTokenTracker({ tokensPerMinute: 0 }) and use that instance
for tracker.drain and assertions so you don't need the try/finally restore.
Update the test to create the tracker with getTokenTracker({ tokensPerMinute: 0
}) and then call tracker.drain(0, "codex", 50) and verify
manager.getMinWaitTimeForFamily("codex") returns Number.MAX_SAFE_INTEGER,
referencing AccountManager, getTokenTracker, tracker.drain and
manager.getMinWaitTimeForFamily.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ac5d9c8b-77d8-48e4-be27-943f0ec2197b

📥 Commits

Reviewing files that changed from the base of the PR and between e5166e1 and b9454c0.

📒 Files selected for processing (7)
  • index.ts
  • lib/accounts.ts
  • lib/rotation.ts
  • test/accounts.test.ts
  • test/index-retry.test.ts
  • test/index.test.ts
  • test/rotation-integration.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/index-retry.test.ts

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

2 issues found across 7 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/accounts.ts">

<violation number="1" location="lib/accounts.ts:221">
P1: Attempted-account keys are derived from mutable account fields, so a refresh can change the key mid-request and let the same account bypass attempted-key filtering.</violation>
</file>

<file name="test/rotation-integration.test.ts">

<violation number="1" location="test/rotation-integration.test.ts:176">
P2: The new assertion is too weak: `toBeGreaterThan(0)` can pass from immediate fractional refill even when tracker state was not actually cleared after renumbering.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Use a per-account immutable id for request attempt tracking so token refresh and account-id hydration cannot let the same account bypass attempted-key filtering within one request.

Co-authored-by: Codex <noreply@openai.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
lib/accounts.ts (1)

685-707: Simplify the IIFE check for current account eligibility.

The inline IIFE on lines 688-691 obscures the intent. A simpler pattern would improve readability.

♻️ Suggested simplification
-		if (
-			currentIndex >= 0 &&
-			currentIndex < count &&
-			((currentAccount) =>
-				!!currentAccount && !attemptedAccountKeys.has(getRequestAttemptKey(currentAccount)))(
-				this.accounts[currentIndex],
-			)
-		) {
-			const currentAccount = this.accounts[currentIndex];
+		const currentAccount = currentIndex >= 0 && currentIndex < count
+			? this.accounts[currentIndex]
+			: undefined;
+		if (
+			currentAccount &&
+			!attemptedAccountKeys.has(getRequestAttemptKey(currentAccount))
+		) {
			if (currentAccount) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/accounts.ts` around lines 685 - 707, The code uses an unnecessary IIFE to
test this.accounts[currentIndex] and attemptedAccountKeys — replace that IIFE
with a direct, readable check: retrieve const currentAccount =
this.accounts[currentIndex] and use if (currentIndex >= 0 && currentIndex <
count && currentAccount &&
!attemptedAccountKeys.has(getRequestAttemptKey(currentAccount))) { ... } then
keep the existing body that calls getAccountAvailabilitySnapshot(currentAccount,
family, model, tokenTracker), sets currentAccount.lastUsed = nowMs() when
eligible, or assigns alreadyCheckedCurrentIndex = currentIndex otherwise; remove
the IIFE wrapper entirely.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@lib/accounts.ts`:
- Around line 685-707: The code uses an unnecessary IIFE to test
this.accounts[currentIndex] and attemptedAccountKeys — replace that IIFE with a
direct, readable check: retrieve const currentAccount =
this.accounts[currentIndex] and use if (currentIndex >= 0 && currentIndex <
count && currentAccount &&
!attemptedAccountKeys.has(getRequestAttemptKey(currentAccount))) { ... } then
keep the existing body that calls getAccountAvailabilitySnapshot(currentAccount,
family, model, tokenTracker), sets currentAccount.lastUsed = nowMs() when
eligible, or assigns alreadyCheckedCurrentIndex = currentIndex otherwise; remove
the IIFE wrapper entirely.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bd0f7dcb-fa93-4b73-abc8-3a96d48ded7b

📥 Commits

Reviewing files that changed from the base of the PR and between b9454c0 and 5677b24.

📒 Files selected for processing (2)
  • lib/accounts.ts
  • test/rotation-integration.test.ts

ndycode and others added 2 commits March 8, 2026 19:45
Simplify the current-account fast path and extend the removal reset test to assert both token and health tracker reset behavior for surviving accounts.

Co-authored-by: Codex <noreply@openai.com>
Reindex health and token tracker entries after account removal so surviving accounts keep their own state instead of being globally reset.

Co-authored-by: Codex <noreply@openai.com>
@ndycode ndycode changed the title fix: harden request-time account rotation 5/5 fix: harden request-time account rotation Mar 10, 2026
@ndycode ndycode added the precise Tight, disciplined, high-signal PR label Mar 14, 2026
Copilot AI review requested due to automatic review settings March 15, 2026 09:51
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens request-time multi-account rotation by introducing a dedicated request-eligible selector, stabilizing per-request attempt identity, and improving wait-time and tracker behavior across account removal/renumbering.

Changes:

  • Add stable per-account request attempt keys and a request-time selector that skips already-attempted accounts.
  • Improve wait-time computation by including local token bucket recovery and per-account blocker maxima.
  • Reindex token/health trackers (and toast debounce state) when accounts are removed and indices are renumbered; expand regression coverage for rotation and retry paths.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
lib/rotation.ts Adds token wait helper + tracker reindexing after account removal.
lib/accounts.ts Adds immutable account identity + request-eligible selector + updated wait computation + tracker reindex hook on removal.
index.ts Switches fetch loop to request-eligible selector, uses attempted-key set, and continues traversal on local token depletion.
test/rotation.test.ts Adds unit coverage for token wait-time helper.
test/accounts.test.ts Adds coverage for token-refill wait, mixed blockers, and disabled-refill semantics.
test/index.test.ts Expands request-time rotation coverage (network/5xx/empty/local-bucket/wait-path) and updates mocks for attempted keys.
test/index-retry.test.ts Updates retry mock to honor attempted keys.
test/rotation-integration.test.ts Adds integration coverage for attempted-key skipping and tracker reset after renumbering.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

lib/accounts.ts Outdated
return waitTimes.length > 0 ? Math.min(...waitTimes) : 0;
if (waitTimes.length === 0) return 0;
const minWait = Math.min(...waitTimes);
return Number.isFinite(minWait) ? minWait : Number.MAX_SAFE_INTEGER;
lib/accounts.ts Outdated
Comment on lines +450 to +452
const quotaKey = model ? `${family}:${model}` : family;
const tokensAvailable = tokenTracker.getTokens(account.index, quotaKey);
const eligible = enabled && !rateLimited && !coolingDown && tokensAvailable >= 1;
Comment on lines +2130 to +2133
vi.mocked(fetchHelpers.createCodexHeaders).mockImplementation(
(_init, _accountId, accessToken) =>
new Headers({ authorization: `Bearer ${String(accessToken)}` }),
);
Comment on lines +124 to +136
reindexAfterRemoval(removedIndex: number): void {
const nextEntries = new Map<string, HealthEntry>();
for (const [key, entry] of this.entries) {
const [indexText, ...quotaParts] = key.split(":");
const index = Number.parseInt(indexText ?? "", 10);
if (!Number.isFinite(index)) continue;
if (index === removedIndex) continue;
const nextIndex = index > removedIndex ? index - 1 : index;
const nextKey = quotaParts.length > 0 ? `${nextIndex}:${quotaParts.join(":")}` : `${nextIndex}`;
nextEntries.set(nextKey, entry);
}
this.entries = nextEntries;
}
Comment on lines +435 to +437
}

private getAccountAvailabilitySnapshot(
Copy link

Choose a reason for hiding this comment

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

Math.POSITIVE_INFINITY can escape into waitTimes but is not guarded before Math.min

when tokenWaitMs is Number.POSITIVE_INFINITY (zero-refill bucket) the code correctly pushes it:

accountWaits.push(Number.isFinite(tokenWaitMs) ? tokenWaitMs : Number.POSITIVE_INFINITY);

and Math.max(...accountWaits)Infinity lands in waitTimes. that is the intended code path. however, the final Math.min(...waitTimes) will return Infinity if every account contributed only an infinite wait, and that value propagates to callers (handled in index.ts via hasFiniteWait). the risk is a future caller of getMinWaitTimeForFamily that does not guard against Infinity (e.g., uses the result directly as a setTimeout delay). setTimeout(fn, Infinity) on windows never fires, silently stalling the retry loop.

worth adding a doc-comment on getMinWaitTimeForFamily calling out the Infinity contract explicitly, so future callers are warned:

/**
 * Returns the minimum wait time (ms) before any enabled account is expected to
 * be available for `family`/`model`. Returns 0 if at least one account is
 * immediately eligible. Returns `Number.POSITIVE_INFINITY` when all accounts
 * are blocked by a zero-refill token bucket and no finite wait can be computed.
 * Callers MUST guard against `Infinity` before passing the result to `setTimeout`.
 */
getMinWaitTimeForFamily(...)
Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/accounts.ts
Line: 435-437

Comment:
**`Math.POSITIVE_INFINITY` can escape into `waitTimes` but is not guarded before `Math.min`**

when `tokenWaitMs` is `Number.POSITIVE_INFINITY` (zero-refill bucket) the code correctly pushes it:

```typescript
accountWaits.push(Number.isFinite(tokenWaitMs) ? tokenWaitMs : Number.POSITIVE_INFINITY);
```

and `Math.max(...accountWaits)``Infinity` lands in `waitTimes`. that is the intended code path. however, the final `Math.min(...waitTimes)` will return `Infinity` if every account contributed only an infinite wait, and that value propagates to callers (handled in `index.ts` via `hasFiniteWait`). the risk is a future caller of `getMinWaitTimeForFamily` that does not guard against `Infinity` (e.g., uses the result directly as a `setTimeout` delay). `setTimeout(fn, Infinity)` on windows never fires, silently stalling the retry loop.

worth adding a doc-comment on `getMinWaitTimeForFamily` calling out the `Infinity` contract explicitly, so future callers are warned:

```typescript
/**
 * Returns the minimum wait time (ms) before any enabled account is expected to
 * be available for `family`/`model`. Returns 0 if at least one account is
 * immediately eligible. Returns `Number.POSITIVE_INFINITY` when all accounts
 * are blocked by a zero-refill token bucket and no finite wait can be computed.
 * Callers MUST guard against `Infinity` before passing the result to `setTimeout`.
 */
getMinWaitTimeForFamily(...)
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

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

Labels

precise Tight, disciplined, high-signal PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants