Skip to content

fix(auth): keep retrying a failed OAuth refresh instead of giving up for good - #42

Open
ankit-thebigred wants to merge 1 commit into
suitedaces:mainfrom
ankit-thebigred:fix/oauth-refresh-gives-up-permanently
Open

fix(auth): keep retrying a failed OAuth refresh instead of giving up for good#42
ankit-thebigred wants to merge 1 commit into
suitedaces:mainfrom
ankit-thebigred:fix/oauth-refresh-gives-up-permanently

Conversation

@ankit-thebigred

Copy link
Copy Markdown

What happens

Chats start failing with API Error: 401 OAuth access token has expired. Re-authenticate to continue. Quitting and relaunching the app fixes it. It comes back hours later.

Why

scheduleTokenRefresh() gives up permanently:

if (!refreshed) {
  emitAuthRequired('OAuth refresh failed after retries');
  nextRefreshAt = null;
  return;              // nothing ever re-arms the timer
}

Three attempts, 5s and 10s apart, so the whole window is ~15 seconds. After that the process has no scheduled refresh at all. The current access token keeps working until it expires, then every request 401s. Only a relaunch recovers, because that is the only thing that calls ensureOAuthToken() again from a clean state.

What triggers it

From one user's gateway log, 49 refresh attempts and 48 failures, in two flavours:

[claude] token refresh error: [TypeError: fetch failed] {
  [cause]: Error: getaddrinfo ENOTFOUND console.anthropic.com
[claude] token refresh failed: 400

ENOTFOUND is wake-from-sleep. The timer was already due while the machine slept, so it fires immediately on wake, before the network is back. Three attempts inside 15 seconds is not enough to ride that out. pmset -g log on the same machine shows maintenance sleep/wake cycles roughly hourly, so it has many chances to land badly. Captive portals and short offline moments do the same thing.

A refresh token is valid far longer than an access token, so a failure here almost never means the credential is genuinely dead. Giving up permanently on a transient network error is the wrong default.

400 looks like a different bug feeding the same failure path. The token endpoint rotates refresh_token, and ensureOAuthToken() is called at the top of every turn with no concurrency guard, so a chat turn and a subagent turn starting together both POST the same refresh token. The server accepts the first and rejects the second with invalid_grant. Three of those in a row and refresh is off for good.

The fix

  • Never leave the timer unarmed. On failure, re-arm with 30s doubling to a 10 minute cap, and keep going. When connectivity returns the next attempt succeeds and normal expiry-based scheduling resumes. authRequired still fires from the third consecutive failure so the UI can surface it, but it no longer means the process has stopped trying.
  • Single-flight the refresh, so concurrent callers share one in-flight request and a rotating refresh token is never spent twice.
  • Re-check token health when the timer fires, since ensureOAuthToken() may already have refreshed while it was pending.
  • ensureOAuthToken() arms a background retry on failure too, so the on-demand path can also recover without a restart.

Verification

npm run typecheck clean. npm run test:claude-provider passes against a real account on the patched build:

auth: {"authenticated":true,"method":"oauth"}
final: {"result":"ok-sdk", ...}
checks: {"sawInit":true,"sawText":true,"gotOk":true}

Plus 8 checks over the compiled output and the new scheduling logic:

# Check Result
A1 permanent give-up branch removed pass
A2 refresh failure no longer nulls the schedule pass
A3 failure path re-arms a retry pass
A4 no caller bypasses the single-flight wrapper pass
B1 backoff doubles 30s to 600s then caps pass
B2 old code gave up inside 15s, shorter than one new retry interval pass
B3 four concurrent callers produce one token POST pass
B4 single-flight releases after settle pass

Being straight about the limits: the retry path is covered by static assertions against the compiled output plus a behavioural replica of the new scheduler, not by a unit test driving the real module, because the refresh internals are module-scoped and have no injection seam. Happy to add one if you would like the timer and fetch factored out to make that possible.

I also have not reproduced the 400 race directly, only inferred it from the log pattern and the absence of a guard. The single-flight change is cheap and correct regardless, but treat that half as reasoned rather than proven.

Not touched

src/providers/codex.ts has the same give-up-permanently shape. Left alone because I have no way to exercise that path.

Related to #38, which reports the same user-visible symptom from the env-propagation angle.

…for good

scheduleTokenRefresh() retried three times over a ~15 second window (5s, 10s)
and then set nextRefreshAt = null and returned. Nothing re-arms the timer after
that, so the process is left with no scheduled refresh at all. The access token
runs out its remaining life and every request 401s with "OAuth access token has
expired" until the user quits and relaunches the app.

The failures that trigger this are overwhelmingly transient. On a laptop the
common one is waking from sleep: the refresh timer is already due, it fires
before the network is back, and all three attempts die on
"getaddrinfo ENOTFOUND console.anthropic.com" inside 15 seconds. A captive
portal or a brief offline moment does the same thing. A refresh token is valid
for far longer than an access token, so a failure here almost never means the
credential is actually dead.

Changes:

- On failure, re-arm instead of clearing. Backoff is 30s doubling to a 10 minute
  cap and keeps running, so the moment connectivity returns the next attempt
  succeeds and normal expiry-based scheduling resumes. authRequired is still
  emitted from the third consecutive failure so the UI can surface it, but it no
  longer means the process has stopped trying.
- Single-flight the refresh. The token endpoint rotates refresh_token, so two
  concurrent POSTs carrying the same one leave the second rejected with 400.
  ensureOAuthToken() is called at the start of every turn and there is no guard,
  so a chat turn and a subagent turn starting together can race. Observed in the
  wild as repeated "token refresh failed: 400". Concurrent callers now share one
  in-flight request.
- Re-check token health when the timer fires. ensureOAuthToken() may already
  have refreshed while the timer was pending, in which case just re-arm.
- ensureOAuthToken() also arms a background retry on failure, so the on-demand
  path can recover without a restart too.

The same give-up-permanently shape exists in src/providers/codex.ts. Left alone
here because I have no way to exercise that path.
@vercel

vercel Bot commented Jul 26, 2026

Copy link
Copy Markdown

@ankit-thebigred is attempting to deploy a commit to the DevApe Team on Vercel.

A member of the Team first needs to authorize it.

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