diff --git a/packages/opencode/src/altimate/plugin/anthropic.ts b/packages/opencode/src/altimate/plugin/anthropic.ts index 12b082e1d1..764cc48965 100644 --- a/packages/opencode/src/altimate/plugin/anthropic.ts +++ b/packages/opencode/src/altimate/plugin/anthropic.ts @@ -76,29 +76,51 @@ export async function AnthropicAuthPlugin(input: PluginInput): Promise { const currentAuth = await getAuth() if (currentAuth.type !== "oauth") return fetch(requestInput, init) - // Refresh token if expired - if (!currentAuth.access || currentAuth.expires < Date.now()) { - const response = await fetch("https://console.anthropic.com/v1/oauth/token", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: currentAuth.refresh, - client_id: CLIENT_ID, - }), - }) - if (!response.ok) throw new Error(`Token refresh failed: ${response.status}`) - const json: TokenResponse = await response.json() - await input.client.auth.set({ - path: { id: "anthropic" }, - body: { - type: "oauth", - refresh: json.refresh_token, - access: json.access_token, - expires: Date.now() + json.expires_in * 1000, - }, - }) - currentAuth.access = json.access_token + // Refresh token if expired or about to expire (30s buffer) + if (!currentAuth.access || currentAuth.expires < Date.now() + 30_000) { + let lastError: Error | undefined + for (let attempt = 0; attempt < 3; attempt++) { + try { + const response = await fetch("https://console.anthropic.com/v1/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: currentAuth.refresh, + client_id: CLIENT_ID, + }), + }) + if (!response.ok) { + const body = await response.text().catch(() => "") + throw new Error( + `Anthropic OAuth token refresh failed (HTTP ${response.status}). ` + + `Try re-authenticating: altimate-code auth login anthropic` + + (body ? ` — ${body.slice(0, 200)}` : ""), + ) + } + const json: TokenResponse = await response.json() + await input.client.auth.set({ + path: { id: "anthropic" }, + body: { + type: "oauth", + refresh: json.refresh_token, + access: json.access_token, + expires: Date.now() + json.expires_in * 1000, + }, + }) + currentAuth.access = json.access_token + currentAuth.expires = Date.now() + json.expires_in * 1000 + lastError = undefined + break + } catch (e) { + lastError = e instanceof Error ? e : new Error(String(e)) + // Don't retry on 4xx (permanent auth failures) — only retry on network errors / 5xx + const is4xx = lastError.message.includes("HTTP 4") + if (is4xx || attempt >= 2) break + await new Promise((r) => setTimeout(r, 1000 * (attempt + 1))) + } + } + if (lastError) throw lastError } // Build headers from incoming request diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index d3bedc30ce..de9911f377 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -128,19 +128,36 @@ async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: Pk } async function refreshAccessToken(refreshToken: string): Promise { - const response = await fetch(`${ISSUER}/oauth/token`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: CLIENT_ID, - }).toString(), - }) - if (!response.ok) { - throw new Error(`Token refresh failed: ${response.status}`) + let lastError: Error | undefined + for (let attempt = 0; attempt < 3; attempt++) { + try { + const response = await fetch(`${ISSUER}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }).toString(), + }) + if (!response.ok) { + const body = await response.text().catch(() => "") + throw new Error( + `Codex OAuth token refresh failed (HTTP ${response.status}). ` + + `Try re-authenticating: altimate-code auth login openai` + + (body ? ` — ${body.slice(0, 200)}` : ""), + ) + } + return response.json() + } catch (e) { + lastError = e instanceof Error ? e : new Error(String(e)) + // Don't retry on 4xx (permanent auth failures) — only retry on network errors / 5xx + const is4xx = lastError.message.includes("HTTP 4") + if (is4xx || attempt >= 2) break + await new Promise((r) => setTimeout(r, 1000 * (attempt + 1))) + } } - return response.json() + throw lastError ?? new Error("Token refresh failed after retries") } const HTML_SUCCESS = ` @@ -436,8 +453,8 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { // Cast to include accountId field const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string } - // Check if token needs refresh - if (!currentAuth.access || currentAuth.expires < Date.now()) { + // Check if token needs refresh (30s buffer to avoid edge-case expiry during request) + if (!currentAuth.access || currentAuth.expires < Date.now() + 30_000) { log.info("refreshing codex access token") const tokens = await refreshAccessToken(currentAuth.refresh) const newAccountId = extractAccountId(tokens) || authWithAccount.accountId diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 5b4e7bdbc0..cc5e0d70e6 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -882,8 +882,22 @@ export namespace MessageV2 { }, { cause: e }, ).toObject() - case e instanceof Error: - return new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject() + case e instanceof Error: { + const msg = e.message || e.name || "Unknown error" + // Token refresh failures should surface as auth errors with recovery instructions + if (/OAuth token refresh failed/i.test(msg)) { + return new MessageV2.AuthError( + { + providerID: ctx.providerID, + message: msg, + }, + { cause: e }, + ).toObject() + } + // Include error class name for better diagnostics when message is generic + const displayMsg = msg === "Error" || msg === e.name ? `${e.name}: ${e.stack?.split("\n")[0] || "unknown error"}` : msg + return new NamedError.Unknown({ message: displayMsg }, { cause: e }).toObject() + } default: try { const parsed = ProviderError.parseStreamError(e) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 6d057f539f..7c2acc4903 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -61,6 +61,10 @@ export namespace SessionRetry { export function retryable(error: ReturnType) { // context overflow errors should not be retried if (MessageV2.ContextOverflowError.isInstance(error)) return undefined + // auth errors (token refresh failures) should be retried — the token may refresh on next attempt + if (MessageV2.AuthError.isInstance(error)) { + return `Authentication failed — retrying. If this persists, run: altimate-code auth login ${error.data.providerID}` + } if (MessageV2.APIError.isInstance(error)) { if (!error.data.isRetryable) return undefined if (error.data.responseBody?.includes("FreeUsageLimitError")) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index eba4a99505..7a598fb8a3 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -122,6 +122,29 @@ describe("session.retry.retryable", () => { expect(SessionRetry.retryable(error)).toBeUndefined() }) + + test("retries auth errors with recovery message", () => { + const error = new MessageV2.AuthError({ + providerID: "anthropic", + message: "Anthropic OAuth token refresh failed (HTTP 401)", + }).toObject() as ReturnType + + const result = SessionRetry.retryable(error) + expect(result).toBeDefined() + expect(result).toContain("Authentication failed") + expect(result).toContain("altimate-code auth login anthropic") + }) + + test("retries auth errors for other providers", () => { + const error = new MessageV2.AuthError({ + providerID: "openai", + message: "Codex OAuth token refresh failed (HTTP 403)", + }).toObject() as ReturnType + + const result = SessionRetry.retryable(error) + expect(result).toBeDefined() + expect(result).toContain("altimate-code auth login openai") + }) }) describe("session.message-v2.fromError", () => { @@ -173,6 +196,41 @@ describe("session.message-v2.fromError", () => { expect(retryable).toBe("Connection reset by server") }) + test("converts token refresh failure to ProviderAuthError", () => { + const error = new Error("Anthropic OAuth token refresh failed (HTTP 401). Try re-authenticating: altimate-code auth login anthropic") + const result = MessageV2.fromError(error, { providerID: "anthropic" }) + + expect(result.name).toBe("ProviderAuthError") + expect((result as any).data.providerID).toBe("anthropic") + expect((result as any).data.message).toContain("token refresh failed") + }) + + test("converts codex token refresh failure to ProviderAuthError", () => { + const error = new Error("Codex OAuth token refresh failed (HTTP 403). Try re-authenticating: altimate-code auth login openai") + const result = MessageV2.fromError(error, { providerID: "openai" }) + + expect(result.name).toBe("ProviderAuthError") + expect((result as any).data.providerID).toBe("openai") + }) + + test("provides descriptive message for generic Error with no message", () => { + const error = new Error() + const result = MessageV2.fromError(error, { providerID: "test" }) + + expect(result.name).toBe("UnknownError") + // Should not be just "Error" — should include stack or context + expect((result as any).data.message).not.toBe("Error") + expect((result as any).data.message.length).toBeGreaterThan(5) + }) + + test("provides descriptive message for TypeError with no message", () => { + const error = new TypeError() + const result = MessageV2.fromError(error, { providerID: "test" }) + + expect(result.name).toBe("UnknownError") + expect((result as any).data.message).toContain("TypeError") + }) + test("marks OpenAI 404 status codes as retryable", () => { const error = new APICallError({ message: "boom",