Skip to content

[Bug]: a time-valid stored Codex pool token is quarantined on the first Responses 401 #2887

Closed

Description

@luvs01

Client or integration

Codex App

Area

Authentication and account pool

Summary

An ordinary stored Codex pool credential can be quarantined on the first pre-stream Responses 401, even when its access token is still time-valid and a refresh token is available.

This is distinct from native __main__ handling in #2221/#2848 and WHAM usage refresh handling in #1932/#2021. Ordinary stored accounts resolve as authCtx.kind === "pool"; their time-valid access token bypasses refresh, while the bounded 401 refresh-and-replay path currently only handles main-pool. The first upstream 401 therefore reaches the terminal credential-failure path, marks the stored account needsReauth, and clears its affinity without trying one same-account refresh.

Expected behavior: for an ordinary stored pool account, the first pre-stream/header 401 should perform one generation-fenced, single-flight forced refresh and rebuild the same account's request exactly once. Only a terminal refresh failure or a second 401 from the matching credential generation should quarantine that account.

Reproduction

  1. Store an ordinary Codex pool account with an access token, refresh token, account ID, needsReauth: false, and expiresAt > Date.now() + 60_000.
  2. Select or fix that account for a /v1/responses request.
  3. In a deterministic test harness, return 401 for the old bearer token, return fresh rotated tokens from the OAuth token endpoint, and return 200 for the replay using the new bearer token.
  4. Observe the current behavior: the old bearer is sent once, the refresh endpoint is not called, the client receives 401, and the account is marked needsReauth with its affinity cleared.

The same gap applies to /v1/responses/compact.

Suggested acceptance boundaries:

  • Refresh and replay at most once, on the same ordinary pool account, for a first pre-stream/header 401.
  • Fence refresh, persistence, and final quarantine by the rejected account ID and credential generation so a stale 401 cannot overwrite or quarantine a concurrently replaced credential.
  • Coalesce concurrent same-generation refreshes into one token-endpoint call and one generation increment.
  • Preserve fixed-account, pin, and affinity semantics; do not select a different account for the first replay.
  • Do not quarantine on cancellation, lock contention, network failure, or another transient refresh error.
  • Do not replay a mid-stream failure, exceed the request's total recovery budget, or expose access/refresh tokens in logs or errors.

Version

2.35.0; also present on dev at 8621acb8756c1656676452f4a6c853d2d720c08f

Operating system

Windows 11 Pro 10.0.26200

Provider and model

OpenAI Codex account pool / native GPT-5.6 family

Logs or error output

Synthetic harness result:
upstream sends: 1 (old bearer)
OAuth refresh calls: 0
client response: 401
stored account: needsReauth=true; affinity removed

Screenshots and supporting files

Not required; the reproduction is deterministic and contains no account data.

Redacted configuration

{
  "codexAuth": {
    "mode": "pool",
    "accountSelectionStrategy": "fill-first"
  }
}

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

  1. added
    bugSomething isn't working
    account-poolOAuth, credentials, Codex pool, quota, failover, plans
    on Aug 29, 2026
  2. lidge-jun commented on Aug 29, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 74 / 80

    이 이슈가 말하는 버그는 한 줄로 요약된다. Codex 풀에 저장해 둔 일반 계정(authCtx.kind === "pool")의 액세스 토큰이 아직 만료 시각 안인데, 첫 Responses 사전 스트림 401만 맞아도 바로 needsReauth로 격리된다. 리프레시 토큰이 있어도 OAuth 토큰 엔드포인트를 한 번도 안 부른다. 제보자가 적어 둔 합성 하네스 결과(업스트림 1회, OAuth refresh 0회, 클라이언트 401, 계정 needsReauth=true, affinity 제거)가 그 동작을 그대로 보여 준다. 버전은 2.35.0이고, 지금 dev HEAD(8621acb87, 패키지 2.36.0)에도 같은 구멍이다. #2221/#2848의 네이티브 __main__ 처리나 #1932/#2021의 WHAM 사용량 새로고침과는 다른 길이다.

    지금 코드가 그렇게 동작하는 이유는 명확하다. src/server/responses/core.ts의 패스스루 복구 루프는 401 재시도 분기를 authCtx.kind === "main-pool"일 때만 연다. 그 안에서 refreshNativeMainForwardAuth로 메인 계정을 한 번 강제 갱신하고 같은 요청을 다시 보낸다. 일반 저장 풀 계정은 kind: "pool"이라 이 분기에 아예 못 들어간다. src/server/responses/compact.ts도 같다. compact 401 재시도도 main-pool만 본다. 그래서 /v1/responses와 /v1/responses/compact 둘 다, 일반 풀 계정의 첫 헤더 401은 곧바로 단말 자격 증명 실패로 떨어진다.

    그다음 단말 경로는 계정을 격리한다. src/codex/routing.ts에서 outcome이 401/403이면 credential로 분류하고, markAccountNeedsReauth를 호출한 뒤 clearThreadAccountMapForAccount로 affinity를 지운다. 액세스 토큰이 시간상 아직 살아 있어도, “한 번 refresh-and-replay”를 안 했기 때문에 같은 결과가 난다. 풀 모드에서 시간 유효 토큰은 갱신을 건너뛰는 쪽이 정상인데, 그 가정과 401 복구가 맞춰져 있지 않다. xAI/GitHub Copilot/Kiro 쪽은 같은 파일에 isOAuth401ReplayProvider로 사전 스트림 401 → 강제 refresh → 1회 재구성 재시도가 이미 있다. Codex 일반 풀만 빠졌다.

    이슈가 적어 둔 수락 경계는 지금 dev 방향과도 잘 맞는다. 같은 계정에서 최대 1회, 거절된 accountId와 credential generation으로 펜스, 같은 generation의 동시 refresh는 한 번으로 합치기, fixed/pin/affinity는 첫 재시도에서 다른 계정으로 바꾸지 않기, 취소·락 경합·네트워크 같은 일시 실패로는 격리하지 않기, mid-stream 실패는 재시도하지 않기. 메인 계정 경로(main-pool)가 이미 비슷한 모양을 갖고 있으니, 일반 pool에 같은 계약을 확장하는 쪽이 자연스럽다. types/config 분할과 무관하고, 닫을 중복도 없다.

    src/server/responses/core.ts 약 3815-3820줄 - 401 refresh-and-replay가 main-pool만 본다. kind === "pool"인 일반 저장 계정은 첫 401에서 이 분기를 건너뛴다.

    src/server/responses/compact.ts 약 679-684줄 - compact도 같은 main-pool 가드다. 이슈가 적은 /v1/responses/compact 구멍이 여기다.

    src/codex/routing.ts 약 2146-2157줄 - 401을 credential로 보고 바로 needsReauth와 affinity 제거를 한다. 같은 계정 1회 refresh를 먼저 시도하는 문이 없다.

    경로/oauth 저장소 - generation 펜스와 single-flight는 src/oauth/store.ts의 mergeAccountCredential/markAccountNeedsReauthIfGeneration 쪽에 이미 재료가 있다. 새 저장 형식을 만들기보다, Responses 복구가 그 계약을 쓰게 연결하는 일이다.

    경로/#2221 #2848 #1932 #2021 - 메인/WHAM 이야기는 이미 따로 있다. 이 이슈를 그쪽으로 합치지 마라. 대상은 일반 저장 풀 계정이다.

    메인테이너의 판단이 필요한 지점

    • 일반 pool 401 복구를 main-pool과 같은 헬퍼로 합칠지, pool 전용 refresh-and-replay를 둘지
    • 두 번째 401(같은 generation)에서만 격리할지, refresh 자체가 단말 실패일 때만 격리할지
    • affinity/fixed 계정일 때 첫 재시도 실패 후 다른 계정으로 돌릴지, 그 요청은 그대로 401로 끝낼지
    • 재현 하네스를 CI에 넣을지, 수동 합성 테스트로만 잠글지

    너의 추천
    이 이슈는 열어 두고, 작은 버그 PR을 받는다. core.ts와 compact.ts에서 일반 pool도 사전 스트림/헤더 401에 한 해 generation 펜스된 강제 refresh + 같은 계정 1회 재시도를 하게 하라. 성공하면 needsReauth를 올리지 마라. refresh 단말 실패나 같은 generation의 두 번째 401에서만 지금 routing 격리를 타게 하라. 제보 수락 경계를 테스트로 고정하라. types/config 분할 때문에 닫을 대상이 아니다.

    이 댓글은 grok-bot이 작성했습니다

  3. lidge-jun commented on Aug 29, 2026

    @lidge-jun
    Owner

    Fixed on dev at 0f4cd2a0b (PR #2889).

    Your diagnosis was exact: ordinary stored accounts resolve as authCtx.kind === "pool", their time-valid access token bypasses refresh, and the bounded 401 refresh-and-replay path only handled main-pool, so the first upstream 401 reached the terminal credential path and marked the account needsReauth with its affinity cleared. Both /v1/responses and /v1/responses/compact now perform one generation-fenced forced refresh and one same-account replay. /v1/chat/completions is covered too, since it bridges into handleResponses.

    Every acceptance boundary you listed is met, and reviewing against them turned up five things the first implementation got wrong:

    • Refresh, replay at most once, same account. One request-local guard; a second 401 falls through to terminal handling. Alternate-account selection stays out of the first replay, so fixed-account and pin semantics are unchanged.
    • Fence by account ID and credential generation. Flights are keyed by refresh grant rather than by account, so the rejected generation is re-checked at entry, in the joined-flight branch before the CAS write, and again after the file lock. Results also carry the grant they were opened for — without that a joiner could write a sibling account's tokens onto itself when the flight owner's credential was replaced mid-wait.
    • Coalesce concurrent same-generation refreshes. One token-endpoint call, one generation increment. A joiner whose stored credential already matches the flight result adopts it rather than re-writing identical bytes, which would otherwise move the generation twice and invalidate the affinity handoff.
    • Preserve affinity. This needed real work: an affinity entry stores the generation it was bound under and liveness requires exact equality, so a successful refresh to G+1 left the entry dead on the next request. Lineage is now proven by the call that performed the CAS.
    • Do not quarantine on transient failures. Only a revoked or expired grant is terminal, matched on the exact OAuth error code. A token-endpoint 5xx, an abort, lock contention, refresh capacity, or a superseded flight all surface as a retryable 503 with Retry-After. A bare invalid_grant is now correctly terminal rather than retried forever.
    • No mid-stream replay, no token exposure. Mid-stream 401s receive the generation fence but never a replay; tokens appear in no log or error path.

    One correction to the report: needsReauth is a process-local set rather than a persisted credential-store field, so the account recovered on restart. Inside a running proxy the effect was exactly as you described — out of rotation with its affinity gone.

    Still open and tracked separately: the sidecar recorders (search, images, live, vision) record pool 401s without the generation fence. That is pre-existing behavior, but a forced refresh makes generation bumps more frequent, so the window where a stale sidecar 401 could retire a freshly refreshed credential is wider than before.

    Thank you for the deterministic harness description — the regression suite is built directly on it.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    account-poolOAuth, credentials, Codex pool, quota, failover, plansbugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions