Skip to content

fix(runtime): surface skip reason in pinned 503 (#486) - #487

Merged
ndycode merged 2 commits into
mainfrom
fix/issue-486-pinned-503-skip-reason
May 27, 2026
Merged

fix(runtime): surface skip reason in pinned 503 (#486)#487
ndycode merged 2 commits into
mainfrom
fix/issue-486-pinned-503-skip-reason

Conversation

@ndycode

@ndycode ndycode commented May 27, 2026

Copy link
Copy Markdown
Owner

Summary

  • Pinned-account 503 response now includes the runtime skip reason (reason field) plus the full account_skip_reasons map, mirroring the existing writePoolExhausted shape.
  • Human-readable message appends the reason in parentheses, for example Pinned account 2 is currently unavailable (rate-limited); run ....
  • A missing skip reason resolves to explicit null and is recorded in status.lastError, so a forecast-vs-runtime state desync is visible instead of silently masked.
  • Updates docs/reference/error-contracts.md to reflect the new payload shape.

Why

Issue #486 reports a 503 from codex_pinned_account_unavailable while codex-multi-auth doctor reports all green. The 503 body did not carry the runtime skip reason, so neither the user nor maintainers could tell whether the pin was rate-limited, cooling down, disabled, blocked by policy, or in some other unavailable state. The non-pinned writePoolExhausted path already surfaces this information; this change brings the pinned path to parity.

This PR lands the diagnostic surface so future occurrences self-describe. The underlying state-desync root cause (doctor green, runtime 503) still requires logs from the reporter to fully diagnose.

Changes

  • lib/runtime-rotation-proxy.ts: extend the pinned-503 response body with reason and account_skip_reasons; append reason to the message; record status.lastError when no skip reason was captured.
  • test/issue-474-pin-end-to-end.test.ts: extend the existing disabled-account case to assert the new fields and add two new end-to-end cases for rate-limited and cooling-down pinned accounts.
  • test/issue-474-pin-honored.test.ts: add a describe block that asserts chooseAccount populates skipReasons for every pinned unavailability path (rate-limited, cooling-down, disabled, policy-blocked, missing, already-attempted).
  • docs/reference/error-contracts.md: document the new pinned-503 fields.

Verification

  • npm run typecheck clean
  • npm run lint:ts clean
  • npm test (vitest) 4014 passed, 268 files, 0 failures

Risk Notes

  • Response shape is additive. Existing consumers reading error.code, error.message, or error.pinnedAccountIndex are unaffected.
  • A null reason is now explicit in the payload instead of absent. Any consumer that did assert(body.error.reason === undefined) would need updating; unlikely but worth calling out.
  • account_skip_reasons mirrors the pool-exhausted shape, so consumers that already handle that payload do not need a new code path.

Follow-ups

  • The original report also mentions plugins being disabled and per-project history disappearing after an uninstall+reinstall cycle. These are separate code paths and should be tracked in their own issues once reproduced.
  • The recurring 503 root cause (state desync between forecast and runtime) still needs logs from ENABLE_PLUGIN_REQUEST_LOGGING=1 against this patch to identify.

Refs issue #486.

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 brings the pinned-account 503 response to parity with writePoolExhausted by surfacing the runtime skip reason in both a structured reason field and the human-readable message, and explicitly records null when no skip reason was captured so a forecast/runtime state desync is visible instead of silently masked.

  • buildPinnedUnavailableErrorBody is extracted as a pure, exported helper so the null-reason desync branch (reason: null, empty account_skip_reasons) can be unit-tested independently from the proxy; the interface mirrors writePoolExhausted for uniform consumer handling.
  • issue-474-pin-honored.test.ts adds full coverage of every chooseAccount skip-reason path (rate-limited, cooling-down, disabled, workspace-disabled, policy-blocked, missing, already-attempted) plus four buildPinnedUnavailableErrorBody unit tests including the null/empty-map case.
  • issue-474-pin-end-to-end.test.ts extends the existing disabled-account assertion and adds two new e2e scenarios (rate-limited, cooling-down) that exercise the full proxy path.

Confidence Score: 5/5

additive response shape change with no breaking modifications to existing fields; all new branches are covered by unit and e2e tests

the change is purely additive — existing consumers reading error.code, error.message, or error.pinnedAccountIndex are unaffected. buildPinnedUnavailableErrorBody is a pure function, easy to reason about, and fully exercised including the null/empty-map desync path. the production if (isPinned) guard ensures pinnedIndex is always a number when the new code runs, so the defensive null handling in the helper is a safe backstop rather than a live risk.

no files require special attention

Important Files Changed

Filename Overview
lib/runtime-rotation-proxy.ts extracts buildPinnedUnavailableErrorBody as an exported pure helper and wires it into the pinned-503 branch; adds status.lastError assignment for the null-reason desync path; response shape is additive and does not break existing consumers
test/issue-474-pin-honored.test.ts adds chooseAccount skip-reason coverage for all six pinned-unavailability paths plus four buildPinnedUnavailableErrorBody unit tests including the null/empty-map (desync) branch
test/issue-474-pin-end-to-end.test.ts extends existing disabled-account e2e assertion and adds rate-limited and cooling-down end-to-end scenarios through the full proxy; test setup correctly keys rate-limit under the model-family string returned by getModelFamily
docs/reference/error-contracts.md documents the new reason, account_skip_reasons, and null-reason semantics for the pinned-account-unavailable 503 payload

Sequence Diagram

sequenceDiagram
    participant Client
    participant Proxy as runtime-rotation-proxy
    participant CA as chooseAccount
    participant AM as AccountManager

    Client->>Proxy: POST /v1/responses (model, pinnedIndex set)
    Proxy->>Proxy: "readStorageMetaFromDisk() → isPinned=true, pinnedIndex=N"
    Proxy->>CA: "chooseAccount({ pinnedIndex, skipReasons })"
    CA->>AM: getAccountByIndex(N)
    CA->>AM: getAccountRuntimeSkipReason(N, family, model)
    AM-->>CA: ""rate-limited" | "cooling-down:X" | "disabled" | ... | null"
    CA->>CA: skipReasons.set(N, reason)
    CA-->>Proxy: null (account unavailable)
    Proxy->>Proxy: buildPinnedUnavailableErrorBody(pinnedIndex, skipReasons)
    alt "reason !== null"
        Proxy->>Proxy: "errorBody.reason = "rate-limited" (or other)"
    else "reason === null (desync)"
        Proxy->>Proxy: "errorBody.reason = null"
        Proxy->>Proxy: "status.lastError = "pinned-503 missing skip reason""
    end
    Proxy->>Proxy: usageRecorder.record(failure, 503)
    Proxy-->>Client: "503 { error: { code, reason, message, account_skip_reasons } }"
Loading

Reviews (2): Last reviewed commit: "fix(runtime): address review feedback on..." | Re-trigger Greptile

The pinned-account 503 response previously omitted the runtime skip
reason, forcing users to consult `codex-multi-auth status` out of band
and making remote diagnosis impossible. The response body now carries a
structured `reason` field and an `account_skip_reasons` map, mirroring
the existing `writePoolExhausted` shape, and the human-readable message
appends the same reason in parentheses.

A missing reason maps to explicit `null` and is captured in
`status.lastError` so a forecast vs. runtime state desync is detectable
instead of silently masked. Updates the error-contract reference doc to
match.

Adds end-to-end coverage for the rate-limited, cooling-down, and
disabled pinned-503 paths and extends the chooseAccount unit tests to
assert skipReasons map population for every pinned unavailability case
(rate-limited, cooling-down, disabled, policy-blocked, missing,
already-attempted).

Closes #486 partial: the diagnostic surface lands now; the underlying
state desync that prompted the report still needs logs from the
reporter to root-cause.
@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

extends runtime rotation proxy error responses to surface pinned-account skip reasons in structured json payloads. adds error-contract documentation, implements skip-reason retrieval and enrichment in the proxy's pinned-account-unavailability path, and covers both unit tests for the chooseAccount function and end-to-end http integration tests.

Changes

Pinned-account error enrichment with skip reasons

Layer / File(s) Summary
Error contract definition
docs/reference/error-contracts.md
extended documented http error payload contract for pool exhaustion and pinned-account unavailability, adding structured reason fields, retry_after_ms hints, and per-account account_skip_reasons maps keyed by account index.
Runtime proxy skip reason implementation
lib/runtime-rotation-proxy.ts
added exported PinnedUnavailableErrorBody and buildPinnedUnavailableErrorBody (lib/runtime-rotation-proxy.ts:1047-1087), and updated the pinned-account 503 handling to use the helper, set status.lastError when reason is null, and write the enriched json payload (lib/runtime-rotation-proxy.ts:1782-1800).
Unit tests for chooseAccount skip reason population
test/issue-474-pin-honored.test.ts
adds tests that verify chooseAccount(..., skipReasons) populates per-index skip reasons for rate-limited, cooling-down, disabled, policy-blocked, missing/out-of-range, already-attempted, workspace-disabled, and circuit-open cases (test/issue-474-pin-honored.test.ts:466-737).
End-to-end tests for pinned-account error responses
test/issue-474-pin-end-to-end.test.ts
updates existing issue #474 assertion to parse 503 json and validate reason, message, pinnedAccountIndex, and account_skip_reasons (test/issue-474-pin-end-to-end.test.ts:282-302), and adds two issue #486 end-to-end tests for rate-limited and cooling-down pinned-account scenarios (test/issue-474-pin-end-to-end.test.ts:305-440).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • ndycode/codex-multi-auth#480: both prs extend lib/runtime-rotation-proxy.ts to emit richer per-account skip-reason diagnostics in 503 json.

Suggested labels

bug

observations

  • tests added and expanded. see unit coverage for builder and chooseAccount at test/issue-474-pin-honored.test.ts:685-737 and test/issue-474-pin-honored.test.ts:466-626. missing explicit regression test that simulates concurrent mutation of the accountSkipReasons map during error construction. add one if concurrency is possible.

  • docs updated at docs/reference/error-contracts.md:80. message format now appends the reason in parentheses. confirm consistency with other proxy error messages.

  • implementation details: buildPinnedUnavailableErrorBody exported and implemented at lib/runtime-rotation-proxy.ts:1047-1087. pinned 503 path uses the helper at lib/runtime-rotation-proxy.ts:1782-1800 and sets status.lastError when the helper returns reason: null. verify status mutation is safe in concurrent request handling.

  • concurrency risk: accountSkipReasons is passed by reference to chooseAccount and later read to build the response (lib/runtime-rotation-proxy.ts:1047-1087, lib/runtime-rotation-proxy.ts:1782-1800). flagged risk: add a readonly snapshot or copy before passing across async boundaries.

  • windows edge cases: end-to-end tests assert message substrings and regexes in test/issue-474-pin-end-to-end.test.ts:383-440. ensure regexes and string comparisons are platform-agnostic for crlf vs lf.

  • missing regression tests: there is no explicit test that covers the diagnostic status.lastError path when reason is null beyond builder unit tests. add an integration test that forces a desync where pinnedIndex exists but no skip reason was recorded, then assert status.lastError is set (lib/runtime-rotation-proxy.ts:1782-1800).

🚥 Pre-merge checks | ✅ 4 | ❌ 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 (4 passed)
Check name Status Explanation
Title check ✅ Passed title follows conventional commits format (fix type, runtime scope, 54 chars under 72-char limit, lowercase imperative). clearly summarizes the main change: surfacing skip reason in pinned 503 responses.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed PR description is comprehensive and well-structured, addressing summary, rationale, changes, verification, and risk assessment with specific file references.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-486-pinned-503-skip-reason
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fix/issue-486-pinned-503-skip-reason

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.

Comment thread lib/runtime-rotation-proxy.ts Outdated

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

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

Inline comments:
In `@test/issue-474-pin-end-to-end.test.ts`:
- Around line 384-437: Update the assertions to check the exact cooling-down
reason string instead of a prefix: replace the regex checks on body.error.reason
and body.error.account_skip_reasons[String(pinnedIndex)] with exact equality to
"cooling-down:auth-failure" (this value is set via
AccountManager.markAccountCoolingDown in the test setup), and keep the existing
assertion on body.error.message as-is if it only needs to include the
cooling-down text in parentheses.
- Around line 305-440: Add a new e2e test modeled after the two existing
pinned-503 tests that sets up a storage with pinnedAccountIndex (use
makeTmpStoragePath/createStorage/writeStorageFile/setStoragePathDirect and new
AccountManager) but do NOT call accountManager.markRateLimitedWithReason or
markAccountCoolingDown on the pinned account; start the proxy via
startRuntimeRotationProxy with a fetchImpl that would not be called, POST via
postViaHttp to "/v1/responses" for model "gpt-5-codex" and assert the response
is 503 and that body.error.reason === null and
body.error.account_skip_reasons[String(pinnedIndex)] === null (and verify
upstreamCalls has length 0), mirroring the assertions style used in the other
two tests.

In `@test/issue-474-pin-honored.test.ts`:
- Around line 467-627: Add two new test cases to the "chooseAccount populates
skipReasons for pinned unavailability" suite that mirror the existing patterns:
one where the pinned account is marked as workspace-disabled and one where it's
treated as circuit-open; call chooseAccount with pinnedIndex set to the target
index and a fresh skipReasons Map, then assert result is null and
skipReasons.get(index) === "workspace-disabled" (for the workspace case) and ===
"circuit-open" (for the circuit case). Locate the suite and follow the same
setup used in other tests (createStorage, new AccountManager, set up the account
state or policy as needed) so the tests exercise the runtime reasons implemented
around the chooseAccount logic and the runtime rotation proxy error handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: e2108ff3-7f76-42b2-94ca-1f10bd1a2a5c

📥 Commits

Reviewing files that changed from the base of the PR and between 42648d6 and 2a3ca54.

📒 Files selected for processing (4)
  • docs/reference/error-contracts.md
  • lib/runtime-rotation-proxy.ts
  • test/issue-474-pin-end-to-end.test.ts
  • test/issue-474-pin-honored.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (13)
docs/reference/**/*.md

📄 CodeRabbit inference engine (docs/DOCUMENTATION.md)

Update relevant command/settings/path references in reference documentation when runtime changes occur

New flags/settings/paths must be reflected in docs/reference/*

Files:

  • docs/reference/error-contracts.md
docs/**/*.md

📄 CodeRabbit inference engine (docs/STYLE_GUIDE.md)

docs/**/*.md: User-facing documentation should follow the page template: Title and one-line lead, Quick path commands, Core operational workflow, Troubleshooting or failure handling, and Related links
Use short sections and scan-friendly tables in documentation where they improve clarity
Prefer direct, actionable language in documentation
Use runnable command examples in documentation
Explain expected outcomes after critical commands in documentation
Keep terminology consistent with runtime names in documentation
Avoid speculative language when behavior is deterministic in documentation
Put the user problem in the first paragraph before implementation detail
Use descriptive page titles such as codex-multi-auth Features instead of generic titles on public docs
Do not repeat keyword lists in every section; search terms should appear only where they help a developer understand the page
Canonical command family is codex-multi-auth ...
Canonical runtime root is ~/.codex/multi-auth
Runtime rotation must be described as default-on unless the release policy changes
Legacy command/path references belong only in migration contexts in documentation
Compatibility aliases (codex multi auth, codex multi-auth, codex multiauth) belong only in command reference, troubleshooting, or migration contexts
Keep command flags aligned with runtime usage text in documentation
Avoid non-runnable command snippets in documentation
Avoid conflicting path guidance across documentation
Avoid legacy-first onboarding language in documentation

Release notes should follow a semantic versioning naming convention in the releases/ directory (e.g., releases/vX.Y.Z.md, including pre-releases like v0.1.0-beta.0.md and archived histories)

Files:

  • docs/reference/error-contracts.md
docs/reference/*.md

📄 CodeRabbit inference engine (docs/README.md)

Create reference documentation in a reference/ directory covering: commands, settings, storage paths, public API contracts, and error semantics

Files:

  • docs/reference/error-contracts.md
docs/**

⚙️ CodeRabbit configuration file

keep README, SECURITY, and docs consistent with actual CLI flags and workflows. whenever behavior changes, require updated upgrade notes and mention new npm scripts.

Files:

  • docs/reference/error-contracts.md
**/*.{ts,tsx,js,mjs}

📄 CodeRabbit inference engine (AGENTS.md)

Use ESM only ("type": "module"), Node >= 18

Files:

  • lib/runtime-rotation-proxy.ts
  • test/issue-474-pin-end-to-end.test.ts
  • test/issue-474-pin-honored.test.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Do not use as any, @ts-ignore, or @ts-expect-error TypeScript assertions

Files:

  • lib/runtime-rotation-proxy.ts
  • test/issue-474-pin-end-to-end.test.ts
  • test/issue-474-pin-honored.test.ts
**/lib/runtime-rotation-proxy.ts

📄 CodeRabbit inference engine (AGENTS.md)

**/lib/runtime-rotation-proxy.ts: Keep runtime rotation default-on behavior aligned with explicit release and migration documentation
Do not expose account emails or tokens in runtime proxy client response headers or logs

Files:

  • lib/runtime-rotation-proxy.ts
lib/**/*.ts

📄 CodeRabbit inference engine (lib/AGENTS.md)

lib/**/*.ts: All public exports should flow through lib/index.ts or documented package subpaths
Never import from dist/ in source tests or library code
Never suppress type errors

Files:

  • lib/runtime-rotation-proxy.ts
lib/runtime-rotation-proxy.ts

📄 CodeRabbit inference engine (lib/AGENTS.md)

lib/runtime-rotation-proxy.ts: Runtime rotation code must preserve pass-through semantics except for auth/provider headers that intentionally change
Runtime proxy client-facing headers must not expose account emails or tokens
Runtime rotation should fail open to normal official Codex forwarding when startup helpers are unavailable
Never add account emails/tokens to runtime proxy client responses

Files:

  • lib/runtime-rotation-proxy.ts
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/runtime-rotation-proxy.ts
{**/scripts/**/*.js,**/test/**/*.ts}

📄 CodeRabbit inference engine (AGENTS.md)

Do not use bare recursive delete logic in Windows-sensitive scripts/tests without retry handling for transient EBUSY/EPERM/ENOTEMPTY errors

Files:

  • test/issue-474-pin-end-to-end.test.ts
  • test/issue-474-pin-honored.test.ts
test/**/*.test.ts

📄 CodeRabbit inference engine (test/AGENTS.md)

test/**/*.test.ts: Vitest globals (describe, it, expect) are enabled and should be used without explicit imports
Maintain 80% coverage threshold across statements, branches, functions, and lines
Use removeWithRetry for Windows filesystem cleanup instead of bare fs.rm to handle EBUSY/EPERM/ENOTEMPTY backoff
Use source files in tests, not compiled dist/ files; test the source directly
Do not skip tests without justification; include rationale if a test must be skipped
Relax ESLint rules for test files as specified in eslint.config.js

Files:

  • test/issue-474-pin-end-to-end.test.ts
  • test/issue-474-pin-honored.test.ts
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/issue-474-pin-end-to-end.test.ts
  • test/issue-474-pin-honored.test.ts
🧠 Learnings (1)
📓 Common learnings
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: CLI exit code 0 indicates successful execution
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: CLI exit code 1 indicates usage error, invalid arguments, sync/persistence failure, or command failure
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: Human-readable command output must be written to stdout
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: Argument/usage and failure diagnostics must be written to stderr
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: On invalid command/arguments, usage text must be printed with a non-zero exit code
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: Unknown subcommand error message must follow the format 'Unknown command: <name>' and include usage text
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: Missing index error in 'switch' command must follow the format 'Missing index. Usage: codex-multi-auth switch <index>'
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: Invalid index error in 'switch' command must follow the format 'Invalid index: <value>'
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: The following commands must support --json flag and produce pretty-printed JSON objects: forecast, report, fix, doctor, verify-flagged
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: JSON output from --json commands must be valid JSON with a 'command' field identifying the command family
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: Documented top-level sections in JSON output must remain stable unless a migration note is provided
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: Upstream entitlement-like 404 payloads must be normalized to 403 with entitlement_error payloads
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: Entitlement errors must not be treated as rate limits
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: Upstream usage-limit indicators must normalize to rate-limit semantics in handleErrorResponse
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: handleErrorResponse may return parsed rateLimit.retryAfterMs metadata
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: Error responses must be normalized to JSON error payloads with a stable error.message field
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: Error diagnostics may include request/correlation IDs when available
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: The default-on localhost Responses proxy must return JSON error payloads with a stable error.code field
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: Runtime rotation proxy error code 'runtime_rotation_proxy_not_found' (404) indicates request path or method is outside the supported Responses/model discovery surface
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: Runtime rotation proxy error code 'runtime_rotation_proxy_unauthorized' (401) indicates local request did not include the per-process proxy client key
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: Runtime rotation proxy error code 'runtime_rotation_proxy_payload_too_large' (413) indicates request body exceeded the proxy safety cap
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: Runtime rotation proxy error code 'codex_runtime_rotation_pool_exhausted' (429 or 503) indicates no managed account can currently service the runtime request, and must include reason, retry_after_ms, and hint to run 'codex-multi-auth rotation status'
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: Runtime rotation proxy error code 'codex_pinned_account_unavailable' (503) indicates a pinned account is rate-limited, cooling down, disabled, or blocked by policy, and must include pinnedAccountIndex, structured reason field with values like rate-limited, cooling-down:auth-failure, circuit-open, disabled, workspace-disabled, policy-blocked, missing, already-attempted or null, and account_skip_reasons map
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: Runtime rotation proxy error code 'codex_runtime_rotation_proxy_error' (500) indicates proxy failed before forwarding the request
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: The selectHybridAccount function must support both positional arguments and options-object forms for backward compatibility
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: The exponentialBackoff function must support both positional arguments and options-object forms for backward compatibility
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: The getTopCandidates function must support both positional arguments and options-object forms for backward compatibility
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: The createCodexHeaders function must support both positional arguments and options-object forms for backward compatibility
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: The getRateLimitBackoffWithReason function must support both positional arguments and options-object forms for backward compatibility
Learnt from: CR
Repo: ndycode/codex-multi-auth

Timestamp: 2026-05-27T15:32:51.336Z
Learning: The transformRequestBody function must support both positional arguments and options-object forms for backward compatibility
🔇 Additional comments (2)
docs/reference/error-contracts.md (1)

80-80: LGTM!

lib/runtime-rotation-proxy.ts (1)

1741-1777: LGTM!

Comment on lines +305 to +440
it(
"surfaces 'rate-limited' skip reason in pinned 503 body (issue #486)",
async () => {
const storagePath = makeTmpStoragePath();
const now = Date.now();
const initialStorage = createStorage(now);
const pinnedIndex = 1;
writeStorageFile(storagePath, {
...initialStorage,
pinnedAccountIndex: pinnedIndex,
affinityGeneration: 1,
});
setStoragePathDirect(storagePath);

const accountManager = new AccountManager(undefined, initialStorage);
openManagers.push(accountManager);

const pinned = accountManager.getAccountByIndex(pinnedIndex);
expect(pinned).not.toBeNull();
if (!pinned) throw new Error("setup failed");
// Match the family the proxy will resolve from `model: "gpt-5-codex"`.
// `getModelFamily("gpt-5-codex")` returns "gpt-5-codex", not "codex",
// so the rate-limit must be keyed under that family for the runtime
// skip-reason check to detect it.
accountManager.markRateLimitedWithReason(
pinned,
60_000,
"gpt-5-codex",
"quota",
);

const upstreamCalls: number[] = [];
const fetchImpl: typeof fetch = async (_input, init) => {
const headers = new Headers(init?.headers);
const auth = headers.get("authorization") ?? "";
const token = auth.replace(/^Bearer\s+/i, "");
const index = initialStorage.accounts.findIndex(
(a) => a.accessToken === token,
);
upstreamCalls.push(index);
return new Response(JSON.stringify({ ok: true, account: index }), {
status: HTTP_STATUS.OK,
headers: { "content-type": "application/json" },
});
};

const proxy = await startRuntimeRotationProxy({
accountManager,
fetchImpl,
upstreamBaseUrl: "https://example.test/backend-api",
clientApiKey: CLIENT_API_KEY,
});
openServers.push(proxy);

const result = await postViaHttp(
proxy,
{ model: "gpt-5-codex", stream: false },
"/v1/responses",
);
expect(result.status).toBe(HTTP_STATUS.SERVICE_UNAVAILABLE);
const body = JSON.parse(result.bodyText) as {
error: {
code: string;
reason: string | null;
account_skip_reasons: Record<string, string>;
message: string;
};
};
expect(body.error.code).toBe("codex_pinned_account_unavailable");
expect(body.error.reason).toBe("rate-limited");
expect(body.error.message).toContain("(rate-limited)");
expect(body.error.account_skip_reasons[String(pinnedIndex)]).toBe(
"rate-limited",
);
expect(upstreamCalls).toHaveLength(0);
},
);

it(
"surfaces a cooling-down skip reason in pinned 503 body (issue #486)",
async () => {
const storagePath = makeTmpStoragePath();
const now = Date.now();
const initialStorage = createStorage(now);
const pinnedIndex = 0;
writeStorageFile(storagePath, {
...initialStorage,
pinnedAccountIndex: pinnedIndex,
affinityGeneration: 1,
});
setStoragePathDirect(storagePath);

const accountManager = new AccountManager(undefined, initialStorage);
openManagers.push(accountManager);

const pinned = accountManager.getAccountByIndex(pinnedIndex);
if (!pinned) throw new Error("setup failed");
accountManager.markAccountCoolingDown(pinned, 60_000, "auth-failure");

const upstreamCalls: number[] = [];
const fetchImpl: typeof fetch = async () => {
upstreamCalls.push(-1);
return new Response("{}", { status: HTTP_STATUS.OK });
};

const proxy = await startRuntimeRotationProxy({
accountManager,
fetchImpl,
upstreamBaseUrl: "https://example.test/backend-api",
clientApiKey: CLIENT_API_KEY,
});
openServers.push(proxy);

const result = await postViaHttp(
proxy,
{ model: "gpt-5-codex", stream: false },
"/v1/responses",
);
expect(result.status).toBe(HTTP_STATUS.SERVICE_UNAVAILABLE);
const body = JSON.parse(result.bodyText) as {
error: {
code: string;
reason: string | null;
account_skip_reasons: Record<string, string>;
message: string;
};
};
expect(body.error.code).toBe("codex_pinned_account_unavailable");
expect(body.error.reason).toMatch(/^cooling-down/);
expect(body.error.message).toMatch(/\(cooling-down[^)]*\)/);
expect(body.error.account_skip_reasons[String(pinnedIndex)]).toMatch(
/^cooling-down/,
);
expect(upstreamCalls).toHaveLength(0);
},
);

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

add an e2e case for reason: null pinned 503 payloads.

the pr objective includes explicit null when no skip reason is recorded, but this block only covers concrete reasons. please add one 503 test that verifies error.reason === null (and matching account_skip_reasons shape) so the desync path stays locked.

Based on learnings: "Runtime rotation proxy error code 'codex_pinned_account_unavailable' (503) ... must include ... structured reason field ... or null ..."

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

In `@test/issue-474-pin-end-to-end.test.ts` around lines 305 - 440, Add a new e2e
test modeled after the two existing pinned-503 tests that sets up a storage with
pinnedAccountIndex (use
makeTmpStoragePath/createStorage/writeStorageFile/setStoragePathDirect and new
AccountManager) but do NOT call accountManager.markRateLimitedWithReason or
markAccountCoolingDown on the pinned account; start the proxy via
startRuntimeRotationProxy with a fetchImpl that would not be called, POST via
postViaHttp to "/v1/responses" for model "gpt-5-codex" and assert the response
is 503 and that body.error.reason === null and
body.error.account_skip_reasons[String(pinnedIndex)] === null (and verify
upstreamCalls has length 0), mirroring the assertions style used in the other
two tests.

Comment thread test/issue-474-pin-end-to-end.test.ts
Comment thread test/issue-474-pin-honored.test.ts
- Extract `buildPinnedUnavailableErrorBody` helper so the null-reason
  state-desync branch can be unit-tested directly without standing up a
  full proxy. The helper is exported alongside a typed
  `PinnedUnavailableErrorBody` interface so external consumers can rely
  on a stable shape.
- Tighten the end-to-end cooling-down assertion from a regex prefix to
  an exact equality check against `cooling-down:auth-failure`, the
  string set by `markAccountCoolingDown` in the test setup. Prevents
  silent contract drift on the cooldown reason format.
- Add chooseAccount unit cases for the remaining pinned skip reasons
  flagged by review: `workspace-disabled` (all workspaces disabled) and
  `circuit-open` (failure threshold tripped via `recordFailure`). The
  suite now mirrors the full enumeration in
  `AccountManager.getAccountRuntimeSkipReason`.
- Add direct unit coverage for `buildPinnedUnavailableErrorBody` over
  four shapes: empty map yields `reason: null` with no parenthetical in
  the message, populated map yields the reason plus the parenthetical,
  null `pinnedIndex` resolves to `pinnedAccountIndex: null` without
  throwing, and the full `account_skip_reasons` map is mirrored even
  when the pinned index has no entry of its own.

Closes #486 partial: review feedback addressed; underlying state desync
still needs reporter logs to root-cause.
@ndycode
ndycode merged commit 387651c into main May 27, 2026
1 of 2 checks passed
@ndycode
ndycode deleted the fix/issue-486-pinned-503-skip-reason branch May 27, 2026 15:53
ndycode added a commit that referenced this pull request May 27, 2026
Prerelease that ships the pinned-account 503 diagnostic surface from
#487 (issue #486) to npm under the `beta` dist-tag. Users who can
reproduce the recurring 503 should install via
`npm i -g codex-multi-auth@beta` so the new structured `reason` and
`account_skip_reasons` fields are visible in their next failure, then
attach the body plus logs to issue #486 for root-cause analysis.

Stable v2.1.13 will land once the underlying forecast-vs-runtime state
desync is identified and patched.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant