Skip to content

Commit 62ec54d

Browse files
authored
fix(codex): refresh and retry once on a 401 (the biggest reliability gap) (#108)
The loader's proactive refresh only catches token EXPIRY. A Codex access token can be revoked/invalidated server-side BEFORE its local `expires` (password change, admin revoke, clock skew, or a lost cross-process refresh race). That surfaced as a 401 returned verbatim — the request just failed, and the only recovery was a manual `keys signin`. Now a 401 on a Codex route forces one refresh and retries once. The refresh re-reads the latest persisted auth first (a sibling process may have already rotated the pair) and adopts a newer token if present, otherwise spends the refresh token and persists the rotated pair. A dead refresh token surfaces the reconnect message; a transient failure keeps the original 401 rather than making things worse. Retries at most once, so it can never loop. Extracted sendWithCodex401Retry() as a pure, dependency-injected helper so the send/refresh/retry control flow is unit-tested.
1 parent 9d53d94 commit 62ec54d

2 files changed

Lines changed: 158 additions & 9 deletions

File tree

backend/cli/src/plugin/codex.ts

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,28 @@ export function classifyDevicePollStatus(status: number): "pending" | "transient
7171
return "fail"
7272
}
7373

74+
/**
75+
* Send a Codex request and, on a 401, refresh the access token once and retry
76+
* once. The loader's proactive refresh only catches token EXPIRY; a token
77+
* revoked/invalidated server-side before its local `expires` (password change,
78+
* admin revoke, clock skew) surfaces as a 401 that was otherwise returned
79+
* verbatim — the request just failed. `refresh` returns the new access token,
80+
* `undefined` to give up (the original 401 is returned unchanged), or throws to
81+
* surface a fatal error (e.g. the refresh token itself is dead). Retries at most
82+
* once, so it can never loop.
83+
*/
84+
export async function sendWithCodex401Retry(
85+
send: (accessToken: string) => Promise<Response>,
86+
accessToken: string,
87+
refresh: () => Promise<string | undefined>,
88+
): Promise<Response> {
89+
const response = await send(accessToken)
90+
if (response.status !== 401) return response
91+
const refreshed = await refresh()
92+
if (!refreshed) return response
93+
return send(refreshed)
94+
}
95+
7496
interface PkceCodes {
7597
verifier: string
7698
challenge: string
@@ -541,10 +563,9 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
541563
}
542564
}
543565

544-
// Set authorization header with access token
545-
headers.set("authorization", `Bearer ${currentAuth.access}`)
546-
547-
// Set ChatGPT-Account-Id header for organization subscriptions
566+
// Set ChatGPT-Account-Id header for organization subscriptions.
567+
// (The authorization header is set per-attempt inside `send` below,
568+
// so the 401-retry path can swap in a freshly-refreshed token.)
548569
if (authWithAccount.accountId) {
549570
headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
550571
}
@@ -588,11 +609,55 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
588609
}
589610
}
590611

591-
const response = await fetch(url, {
592-
...init,
593-
headers,
594-
body: bodyForRequest,
595-
})
612+
// Send with the current bearer; re-called by the 401-retry path with
613+
// a refreshed token. Each attempt re-sets the authorization header.
614+
const send = (accessToken: string) => {
615+
headers.set("authorization", `Bearer ${accessToken}`)
616+
return fetch(url, { ...init, headers, body: bodyForRequest })
617+
}
618+
619+
// Reactive (on-401) refresh: the token was rejected even though it
620+
// isn't locally expired. Re-read the latest persisted auth first (a
621+
// sibling process may have already rotated the pair) and adopt a
622+
// newer token if one exists; otherwise spend the refresh token and
623+
// persist the rotated pair. Returns the fresh access token, undefined
624+
// to keep the original 401 (transient failure), or throws when the
625+
// refresh token itself is dead.
626+
const forceRefreshOn401 = async (): Promise<string | undefined> => {
627+
try {
628+
const latest = (await getAuth()) as typeof currentAuth & { accountId?: string }
629+
if (latest.type !== "oauth") return undefined
630+
if (latest.access && latest.access !== currentAuth.access && latest.expires > Date.now()) {
631+
currentAuth.access = latest.access
632+
authWithAccount.accountId = latest.accountId ?? authWithAccount.accountId
633+
return latest.access
634+
}
635+
const tokens = await refreshAccessTokenSingleFlight(latest.refresh)
636+
const newAccountId = extractAccountId(tokens) || authWithAccount.accountId
637+
await input.client.auth.set({
638+
path: { id: "openai-codex" },
639+
body: {
640+
type: "oauth",
641+
refresh: tokens.refresh_token,
642+
access: tokens.access_token,
643+
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
644+
...(newAccountId && { accountId: newAccountId }),
645+
},
646+
})
647+
currentAuth.access = tokens.access_token
648+
authWithAccount.accountId = newAccountId
649+
return tokens.access_token
650+
} catch (e) {
651+
if (e instanceof CodexRefreshInvalidError)
652+
throw new Error("Codex sign-in expired. Reconnect it with `openscience keys signin`.")
653+
log.warn("codex 401-triggered refresh failed", { error: String(e) })
654+
return undefined
655+
}
656+
}
657+
658+
const response = isCodexRoute
659+
? await sendWithCodex401Retry(send, currentAuth.access, forceRefreshOn401)
660+
: await send(currentAuth.access)
596661

597662
// Fallback to shared key if OAuth quota exceeded
598663
if (response.status === 429 || response.status === 403) {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { test, expect } from "bun:test"
2+
import { sendWithCodex401Retry } from "../../src/plugin/codex"
3+
4+
// A Codex token can be revoked/invalidated server-side before its local
5+
// `expires`, surfacing as a 401. That must trigger exactly one refresh + one
6+
// retry — never a verbatim failure, and never an infinite loop.
7+
8+
test("a 2xx response returns immediately without refreshing", async () => {
9+
let sends = 0
10+
let refreshes = 0
11+
const res = await sendWithCodex401Retry(
12+
async () => {
13+
sends++
14+
return new Response("ok", { status: 200 })
15+
},
16+
"access-1",
17+
async () => {
18+
refreshes++
19+
return "access-2"
20+
},
21+
)
22+
expect(res.status).toBe(200)
23+
expect(sends).toBe(1)
24+
expect(refreshes).toBe(0)
25+
})
26+
27+
test("a 401 refreshes once and retries once with the new token", async () => {
28+
const tokens: string[] = []
29+
let refreshes = 0
30+
const res = await sendWithCodex401Retry(
31+
async (token) => {
32+
tokens.push(token)
33+
return new Response(null, { status: tokens.length === 1 ? 401 : 200 })
34+
},
35+
"stale",
36+
async () => {
37+
refreshes++
38+
return "fresh"
39+
},
40+
)
41+
expect(res.status).toBe(200)
42+
expect(refreshes).toBe(1)
43+
expect(tokens).toEqual(["stale", "fresh"])
44+
})
45+
46+
test("when refresh gives up (undefined), the original 401 is returned unchanged", async () => {
47+
let sends = 0
48+
const res = await sendWithCodex401Retry(
49+
async () => {
50+
sends++
51+
return new Response("unauthorized", { status: 401 })
52+
},
53+
"stale",
54+
async () => undefined,
55+
)
56+
expect(res.status).toBe(401)
57+
expect(sends).toBe(1)
58+
})
59+
60+
test("retries at most once — a persistent 401 does not loop", async () => {
61+
let sends = 0
62+
const res = await sendWithCodex401Retry(
63+
async () => {
64+
sends++
65+
return new Response(null, { status: 401 })
66+
},
67+
"stale",
68+
async () => "fresh",
69+
)
70+
expect(res.status).toBe(401)
71+
expect(sends).toBe(2)
72+
})
73+
74+
test("a fatal refresh error propagates to the caller", async () => {
75+
await expect(
76+
sendWithCodex401Retry(
77+
async () => new Response(null, { status: 401 }),
78+
"stale",
79+
async () => {
80+
throw new Error("Codex sign-in expired. Reconnect it with `openscience keys signin`.")
81+
},
82+
),
83+
).rejects.toThrow("Reconnect it with")
84+
})

0 commit comments

Comments
 (0)