Skip to content

Commit daae6ce

Browse files
anandgupta42claude
andauthored
fix: OAuth token refresh retry and error handling for idle timeout (#118) (#133)
After 20+ minutes idle, OAuth tokens expire and subsequent prompts show unhelpful "Error" with no context or retry. This commit fixes the issue across Anthropic and Codex OAuth plugins: - Add 3-attempt retry with backoff for token refresh (network/5xx only) - Fail fast on 4xx auth errors (permanent failures like revoked tokens) - Add 30-second proactive refresh buffer to prevent mid-request expiry - Update `currentAuth.expires` after successful refresh - Classify token refresh failures as `ProviderAuthError` for actionable error messages with recovery instructions - Make auth errors retryable at session level with user-facing guidance - Improve generic `Error` display (no more bare "Error" in TUI) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8f6807d commit daae6ce

File tree

5 files changed

+154
-39
lines changed

5 files changed

+154
-39
lines changed

packages/opencode/src/altimate/plugin/anthropic.ts

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -76,29 +76,51 @@ export async function AnthropicAuthPlugin(input: PluginInput): Promise<Hooks> {
7676
const currentAuth = await getAuth()
7777
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
7878

79-
// Refresh token if expired
80-
if (!currentAuth.access || currentAuth.expires < Date.now()) {
81-
const response = await fetch("https://console.anthropic.com/v1/oauth/token", {
82-
method: "POST",
83-
headers: { "Content-Type": "application/json" },
84-
body: JSON.stringify({
85-
grant_type: "refresh_token",
86-
refresh_token: currentAuth.refresh,
87-
client_id: CLIENT_ID,
88-
}),
89-
})
90-
if (!response.ok) throw new Error(`Token refresh failed: ${response.status}`)
91-
const json: TokenResponse = await response.json()
92-
await input.client.auth.set({
93-
path: { id: "anthropic" },
94-
body: {
95-
type: "oauth",
96-
refresh: json.refresh_token,
97-
access: json.access_token,
98-
expires: Date.now() + json.expires_in * 1000,
99-
},
100-
})
101-
currentAuth.access = json.access_token
79+
// Refresh token if expired or about to expire (30s buffer)
80+
if (!currentAuth.access || currentAuth.expires < Date.now() + 30_000) {
81+
let lastError: Error | undefined
82+
for (let attempt = 0; attempt < 3; attempt++) {
83+
try {
84+
const response = await fetch("https://console.anthropic.com/v1/oauth/token", {
85+
method: "POST",
86+
headers: { "Content-Type": "application/json" },
87+
body: JSON.stringify({
88+
grant_type: "refresh_token",
89+
refresh_token: currentAuth.refresh,
90+
client_id: CLIENT_ID,
91+
}),
92+
})
93+
if (!response.ok) {
94+
const body = await response.text().catch(() => "")
95+
throw new Error(
96+
`Anthropic OAuth token refresh failed (HTTP ${response.status}). ` +
97+
`Try re-authenticating: altimate-code auth login anthropic` +
98+
(body ? ` — ${body.slice(0, 200)}` : ""),
99+
)
100+
}
101+
const json: TokenResponse = await response.json()
102+
await input.client.auth.set({
103+
path: { id: "anthropic" },
104+
body: {
105+
type: "oauth",
106+
refresh: json.refresh_token,
107+
access: json.access_token,
108+
expires: Date.now() + json.expires_in * 1000,
109+
},
110+
})
111+
currentAuth.access = json.access_token
112+
currentAuth.expires = Date.now() + json.expires_in * 1000
113+
lastError = undefined
114+
break
115+
} catch (e) {
116+
lastError = e instanceof Error ? e : new Error(String(e))
117+
// Don't retry on 4xx (permanent auth failures) — only retry on network errors / 5xx
118+
const is4xx = lastError.message.includes("HTTP 4")
119+
if (is4xx || attempt >= 2) break
120+
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
121+
}
122+
}
123+
if (lastError) throw lastError
102124
}
103125

104126
// Build headers from incoming request

packages/opencode/src/plugin/codex.ts

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -128,19 +128,36 @@ async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: Pk
128128
}
129129

130130
async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
131-
const response = await fetch(`${ISSUER}/oauth/token`, {
132-
method: "POST",
133-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
134-
body: new URLSearchParams({
135-
grant_type: "refresh_token",
136-
refresh_token: refreshToken,
137-
client_id: CLIENT_ID,
138-
}).toString(),
139-
})
140-
if (!response.ok) {
141-
throw new Error(`Token refresh failed: ${response.status}`)
131+
let lastError: Error | undefined
132+
for (let attempt = 0; attempt < 3; attempt++) {
133+
try {
134+
const response = await fetch(`${ISSUER}/oauth/token`, {
135+
method: "POST",
136+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
137+
body: new URLSearchParams({
138+
grant_type: "refresh_token",
139+
refresh_token: refreshToken,
140+
client_id: CLIENT_ID,
141+
}).toString(),
142+
})
143+
if (!response.ok) {
144+
const body = await response.text().catch(() => "")
145+
throw new Error(
146+
`Codex OAuth token refresh failed (HTTP ${response.status}). ` +
147+
`Try re-authenticating: altimate-code auth login openai` +
148+
(body ? ` — ${body.slice(0, 200)}` : ""),
149+
)
150+
}
151+
return response.json()
152+
} catch (e) {
153+
lastError = e instanceof Error ? e : new Error(String(e))
154+
// Don't retry on 4xx (permanent auth failures) — only retry on network errors / 5xx
155+
const is4xx = lastError.message.includes("HTTP 4")
156+
if (is4xx || attempt >= 2) break
157+
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
158+
}
142159
}
143-
return response.json()
160+
throw lastError ?? new Error("Token refresh failed after retries")
144161
}
145162

146163
const HTML_SUCCESS = `<!doctype html>
@@ -436,8 +453,8 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
436453
// Cast to include accountId field
437454
const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string }
438455

439-
// Check if token needs refresh
440-
if (!currentAuth.access || currentAuth.expires < Date.now()) {
456+
// Check if token needs refresh (30s buffer to avoid edge-case expiry during request)
457+
if (!currentAuth.access || currentAuth.expires < Date.now() + 30_000) {
441458
log.info("refreshing codex access token")
442459
const tokens = await refreshAccessToken(currentAuth.refresh)
443460
const newAccountId = extractAccountId(tokens) || authWithAccount.accountId

packages/opencode/src/session/message-v2.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -882,8 +882,22 @@ export namespace MessageV2 {
882882
},
883883
{ cause: e },
884884
).toObject()
885-
case e instanceof Error:
886-
return new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject()
885+
case e instanceof Error: {
886+
const msg = e.message || e.name || "Unknown error"
887+
// Token refresh failures should surface as auth errors with recovery instructions
888+
if (/OAuth token refresh failed/i.test(msg)) {
889+
return new MessageV2.AuthError(
890+
{
891+
providerID: ctx.providerID,
892+
message: msg,
893+
},
894+
{ cause: e },
895+
).toObject()
896+
}
897+
// Include error class name for better diagnostics when message is generic
898+
const displayMsg = msg === "Error" || msg === e.name ? `${e.name}: ${e.stack?.split("\n")[0] || "unknown error"}` : msg
899+
return new NamedError.Unknown({ message: displayMsg }, { cause: e }).toObject()
900+
}
887901
default:
888902
try {
889903
const parsed = ProviderError.parseStreamError(e)

packages/opencode/src/session/retry.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ export namespace SessionRetry {
6161
export function retryable(error: ReturnType<NamedError["toObject"]>) {
6262
// context overflow errors should not be retried
6363
if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
64+
// auth errors (token refresh failures) should be retried — the token may refresh on next attempt
65+
if (MessageV2.AuthError.isInstance(error)) {
66+
return `Authentication failed — retrying. If this persists, run: altimate-code auth login ${error.data.providerID}`
67+
}
6468
if (MessageV2.APIError.isInstance(error)) {
6569
if (!error.data.isRetryable) return undefined
6670
if (error.data.responseBody?.includes("FreeUsageLimitError"))

packages/opencode/test/session/retry.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,29 @@ describe("session.retry.retryable", () => {
122122

123123
expect(SessionRetry.retryable(error)).toBeUndefined()
124124
})
125+
126+
test("retries auth errors with recovery message", () => {
127+
const error = new MessageV2.AuthError({
128+
providerID: "anthropic",
129+
message: "Anthropic OAuth token refresh failed (HTTP 401)",
130+
}).toObject() as ReturnType<NamedError["toObject"]>
131+
132+
const result = SessionRetry.retryable(error)
133+
expect(result).toBeDefined()
134+
expect(result).toContain("Authentication failed")
135+
expect(result).toContain("altimate-code auth login anthropic")
136+
})
137+
138+
test("retries auth errors for other providers", () => {
139+
const error = new MessageV2.AuthError({
140+
providerID: "openai",
141+
message: "Codex OAuth token refresh failed (HTTP 403)",
142+
}).toObject() as ReturnType<NamedError["toObject"]>
143+
144+
const result = SessionRetry.retryable(error)
145+
expect(result).toBeDefined()
146+
expect(result).toContain("altimate-code auth login openai")
147+
})
125148
})
126149

127150
describe("session.message-v2.fromError", () => {
@@ -173,6 +196,41 @@ describe("session.message-v2.fromError", () => {
173196
expect(retryable).toBe("Connection reset by server")
174197
})
175198

199+
test("converts token refresh failure to ProviderAuthError", () => {
200+
const error = new Error("Anthropic OAuth token refresh failed (HTTP 401). Try re-authenticating: altimate-code auth login anthropic")
201+
const result = MessageV2.fromError(error, { providerID: "anthropic" })
202+
203+
expect(result.name).toBe("ProviderAuthError")
204+
expect((result as any).data.providerID).toBe("anthropic")
205+
expect((result as any).data.message).toContain("token refresh failed")
206+
})
207+
208+
test("converts codex token refresh failure to ProviderAuthError", () => {
209+
const error = new Error("Codex OAuth token refresh failed (HTTP 403). Try re-authenticating: altimate-code auth login openai")
210+
const result = MessageV2.fromError(error, { providerID: "openai" })
211+
212+
expect(result.name).toBe("ProviderAuthError")
213+
expect((result as any).data.providerID).toBe("openai")
214+
})
215+
216+
test("provides descriptive message for generic Error with no message", () => {
217+
const error = new Error()
218+
const result = MessageV2.fromError(error, { providerID: "test" })
219+
220+
expect(result.name).toBe("UnknownError")
221+
// Should not be just "Error" — should include stack or context
222+
expect((result as any).data.message).not.toBe("Error")
223+
expect((result as any).data.message.length).toBeGreaterThan(5)
224+
})
225+
226+
test("provides descriptive message for TypeError with no message", () => {
227+
const error = new TypeError()
228+
const result = MessageV2.fromError(error, { providerID: "test" })
229+
230+
expect(result.name).toBe("UnknownError")
231+
expect((result as any).data.message).toContain("TypeError")
232+
})
233+
176234
test("marks OpenAI 404 status codes as retryable", () => {
177235
const error = new APICallError({
178236
message: "boom",

0 commit comments

Comments
 (0)