Skip to content

fix(codex): single-flight token refresh — concurrent expiry race clears freshly rotated tokens#42

Open
olegbrok wants to merge 4 commits into
raine:mainfrom
olegbrok:fix/refresh-single-flight
Open

fix(codex): single-flight token refresh — concurrent expiry race clears freshly rotated tokens#42
olegbrok wants to merge 4 commits into
raine:mainfrom
olegbrok:fix/refresh-single-flight

Conversation

@olegbrok

Copy link
Copy Markdown

Problem

CodexAuthManager::refresh_now isn't serialized: the cached mutex guards only cache reads/writes. N concurrent requests that hit the expiry margin each POST /oauth/token with the same (single-use, rotating) refresh token. The first rotates it; the rest get 401/403 — and the 401 path calls clear_auth(), destroying the tokens the winner just saved.

Production signature (observed 2026-07-12 on a machine running agent fan-outs through the proxy): minutes-long windows where every request 401s, starting exactly when many parallel requests coincide with token expiry, self-recovering only when a late refresh flight happens to win. Between windows everything is fine, which makes it easy to misattribute (we spent a night blaming per-model flakiness — the correlation was just which traffic bursts during a bad window).

Fix

  • refresh_now holds a dedicated refresh_flight mutex for the whole refresh; racing callers block, then re-check the store under the lock and return the winner's tokens without touching the token endpoint.
  • On refresh 401/403, if the stored refresh token has rotated since our read (e.g. another process sharing the Keychain entry), return the rotated tokens instead of clearing auth. Clearing now only happens when the failure is against the current stored token.

Notes

The grok provider already guards this exact race (see concurrent_stale_401_refreshes_reuse_the_rotated_access_token) — this brings codex to parity. Two unit tests added mirroring that pattern (the re-check path is provable without an HTTP mock: reaching the network would fail the test).

Tests: full suite green (415 passing incl. 2 new).

Barsik and others added 4 commits July 11, 2026 18:05
The Responses Lite lane used for gpt-5.6-{sol,terra,luna} moves every tool
into an AdditionalTools developer prefix and sends top-level tools: null.
Upstream only supports function/custom tools on that lane, so a hosted
web_search tool is never registered:

- a forced {type: tool, name: web_search} tool_choice translates to a
  top-level web_search tool_choice that references no registered tool,
  which upstream rejects with 502
  "Tool choice 'web_search_preview' not found in 'tools' parameter" (raine#26)
- unforced searches never engage real web search, so Claude Code's
  WebSearch reports zero results

Registering the web_search tool top-level on the lite lane is not an
option — upstream rejects it: "X-OpenAI-Internal-Codex-Responses-Lite
only supports function tools, custom tools, and client-executed tool
search."

Fix: requests carrying a hosted web_search tool use the full Responses
API instead of the lite lane, where web_search registers top-level and
searches execute (verified live: real results through gpt-5.6-sol).
Also downgrade a web_search tool_choice to auto whenever no web_search
tool ended up registered, so no request shape can reproduce the 502.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live A/B on gpt-5.6-sol: with false, searches often miss the target
(unrelated results); with true the same query returns the correct repo
with utm_source=openai live-search markers. Local deploy only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ults with them)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… on refresh 401

Concurrent requests hitting the expiry margin each launched their own
refresh with the same single-use rotating refresh token: the first
rotates it, the losers get 401 from the token endpoint and clear_auth()
destroys the winner's freshly saved tokens. In production this shows as
minutes-long windows where every request 401s (agent fan-outs at token
expiry), self-recovering only when a late flight wins the race.

- refresh_now now holds a dedicated flight lock; racing callers re-check
  the store under the lock and return the winner's tokens without
  touching the token endpoint
- on refresh 401/403, if the stored refresh token rotated since our
  read (another process sharing the store), return the rotated tokens
  instead of clearing auth

The grok provider already guards this exact race
(concurrent_stale_401_refreshes_reuse_the_rotated_access_token); this
brings codex to parity.
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