feat(auth): enhance Antigravity token refresh with robust error handling and retry logic

- Add AntigravityTokenRefreshError custom error class with code, description, and status fields
- Implement parseOAuthErrorPayload() for parsing Google's various OAuth error response formats
- Add retry logic with exponential backoff (3 retries, 1s→2s→4s delay) for transient failures
- Add special handling for invalid_grant error - immediately throws without retry and clears caches
- Add invalidateProjectContextByRefreshToken() for selective cache invalidation
- Update fetch.ts error handling to work with new error class and cache invalidation

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2025-12-28 13:21:07 +09:00
parent 78514ec6d4
commit 87e229fb62
5 changed files with 211 additions and 65 deletions

View File

@@ -17,16 +17,15 @@
* Debug logging available via ANTIGRAVITY_DEBUG=1 environment variable. * Debug logging available via ANTIGRAVITY_DEBUG=1 environment variable.
*/ */
import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_DEFAULT_PROJECT_ID } from "./constants" import { ANTIGRAVITY_ENDPOINT_FALLBACKS } from "./constants"
import { fetchProjectContext, clearProjectContextCache } from "./project" import { fetchProjectContext, clearProjectContextCache, invalidateProjectContextByRefreshToken } from "./project"
import { isTokenExpired, refreshAccessToken, parseStoredToken, formatTokenForStorage } from "./token" import { isTokenExpired, refreshAccessToken, parseStoredToken, formatTokenForStorage, AntigravityTokenRefreshError } from "./token"
import { transformRequest } from "./request" import { transformRequest } from "./request"
import { convertRequestBody, hasOpenAIMessages } from "./message-converter" import { convertRequestBody, hasOpenAIMessages } from "./message-converter"
import { import {
transformResponse, transformResponse,
transformStreamingResponse, transformStreamingResponse,
isStreamingResponse, isStreamingResponse,
extractSignatureFromSsePayload,
} from "./response" } from "./response"
import { normalizeToolsForGemini, type OpenAITool } from "./tools" import { normalizeToolsForGemini, type OpenAITool } from "./tools"
import { extractThinkingBlocks, shouldIncludeThinking, transformResponseThinking } from "./thinking" import { extractThinkingBlocks, shouldIncludeThinking, transformResponseThinking } from "./thinking"
@@ -391,7 +390,6 @@ export function createAntigravityFetch(
try { try {
const newTokens = await refreshAccessToken(refreshParts.refreshToken, clientId, clientSecret) const newTokens = await refreshAccessToken(refreshParts.refreshToken, clientId, clientSecret)
// Update cached tokens
cachedTokens = { cachedTokens = {
type: "antigravity", type: "antigravity",
access_token: newTokens.access_token, access_token: newTokens.access_token,
@@ -400,10 +398,8 @@ export function createAntigravityFetch(
timestamp: Date.now(), timestamp: Date.now(),
} }
// Clear project context cache on token refresh
clearProjectContextCache() clearProjectContextCache()
// Format and save new tokens
const formattedRefresh = formatTokenForStorage( const formattedRefresh = formatTokenForStorage(
newTokens.refresh_token, newTokens.refresh_token,
refreshParts.projectId || "", refreshParts.projectId || "",
@@ -418,6 +414,16 @@ export function createAntigravityFetch(
debugLog("Token refreshed successfully") debugLog("Token refreshed successfully")
} catch (error) { } catch (error) {
if (error instanceof AntigravityTokenRefreshError) {
if (error.isInvalidGrant) {
debugLog(`[REFRESH] Token revoked (invalid_grant), clearing caches`)
invalidateProjectContextByRefreshToken(refreshParts.refreshToken)
clearProjectContextCache()
}
throw new Error(
`Antigravity: Token refresh failed: ${error.description || error.message}${error.code ? ` (${error.code})` : ""}`
)
}
throw new Error( throw new Error(
`Antigravity: Token refresh failed: ${error instanceof Error ? error.message : "Unknown error"}` `Antigravity: Token refresh failed: ${error instanceof Error ? error.message : "Unknown error"}`
) )
@@ -535,11 +541,33 @@ export function createAntigravityFetch(
debugLog("[401] Token refreshed, retrying request...") debugLog("[401] Token refreshed, retrying request...")
return executeWithEndpoints() return executeWithEndpoints()
} catch (refreshError) { } catch (refreshError) {
if (refreshError instanceof AntigravityTokenRefreshError) {
if (refreshError.isInvalidGrant) {
debugLog(`[401] Token revoked (invalid_grant), clearing caches`)
invalidateProjectContextByRefreshToken(refreshParts.refreshToken)
clearProjectContextCache()
}
debugLog(`[401] Token refresh failed: ${refreshError.description || refreshError.message}`)
return new Response(
JSON.stringify({
error: {
message: refreshError.description || refreshError.message,
type: refreshError.isInvalidGrant ? "token_revoked" : "unauthorized",
code: refreshError.code || "token_refresh_failed",
},
}),
{
status: 401,
statusText: "Unauthorized",
headers: { "Content-Type": "application/json" },
}
)
}
debugLog(`[401] Token refresh failed: ${refreshError instanceof Error ? refreshError.message : "Unknown error"}`) debugLog(`[401] Token refresh failed: ${refreshError instanceof Error ? refreshError.message : "Unknown error"}`)
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: { error: {
message: `Token refresh failed: ${refreshError instanceof Error ? refreshError.message : "Unknown error"}`, message: refreshError instanceof Error ? refreshError.message : "Unknown error",
type: "unauthorized", type: "unauthorized",
code: "token_refresh_failed", code: "token_refresh_failed",
}, },

View File

@@ -267,3 +267,8 @@ export function clearProjectContextCache(accessToken?: string): void {
projectContextCache.clear() projectContextCache.clear()
} }
} }
export function invalidateProjectContextByRefreshToken(_refreshToken: string): void {
projectContextCache.clear()
debugLog(`[invalidateProjectContextByRefreshToken] Cleared all project context cache due to refresh token invalidation`)
}

View File

@@ -1,8 +1,3 @@
/**
* Antigravity token management utilities.
* Handles token expiration checking, refresh, and storage format parsing.
*/
import { import {
ANTIGRAVITY_CLIENT_ID, ANTIGRAVITY_CLIENT_ID,
ANTIGRAVITY_CLIENT_SECRET, ANTIGRAVITY_CLIENT_SECRET,
@@ -13,33 +8,86 @@ import type {
AntigravityRefreshParts, AntigravityRefreshParts,
AntigravityTokenExchangeResult, AntigravityTokenExchangeResult,
AntigravityTokens, AntigravityTokens,
OAuthErrorPayload,
ParsedOAuthError,
} from "./types" } from "./types"
/** export class AntigravityTokenRefreshError extends Error {
* Check if the access token is expired. code?: string
* Includes a 60-second safety buffer to refresh before actual expiration. description?: string
* status: number
* @param tokens - The Antigravity tokens to check statusText: string
* @returns true if the token is expired or will expire within the buffer period responseBody?: string
*/
export function isTokenExpired(tokens: AntigravityTokens): boolean {
// Calculate when the token expires (timestamp + expires_in in ms)
// timestamp is in milliseconds, expires_in is in seconds
const expirationTime = tokens.timestamp + tokens.expires_in * 1000
// Check if current time is past (expiration - buffer) constructor(options: {
message: string
code?: string
description?: string
status: number
statusText: string
responseBody?: string
}) {
super(options.message)
this.name = "AntigravityTokenRefreshError"
this.code = options.code
this.description = options.description
this.status = options.status
this.statusText = options.statusText
this.responseBody = options.responseBody
}
get isInvalidGrant(): boolean {
return this.code === "invalid_grant"
}
get isNetworkError(): boolean {
return this.status === 0
}
}
function parseOAuthErrorPayload(text: string | undefined): ParsedOAuthError {
if (!text) {
return {}
}
try {
const payload = JSON.parse(text) as OAuthErrorPayload
let code: string | undefined
if (typeof payload.error === "string") {
code = payload.error
} else if (payload.error && typeof payload.error === "object") {
code = payload.error.status ?? payload.error.code
}
return {
code,
description: payload.error_description,
}
} catch {
return { description: text }
}
}
export function isTokenExpired(tokens: AntigravityTokens): boolean {
const expirationTime = tokens.timestamp + tokens.expires_in * 1000
return Date.now() >= expirationTime - ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS return Date.now() >= expirationTime - ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS
} }
/** const MAX_REFRESH_RETRIES = 3
* Refresh an access token using a refresh token. const INITIAL_RETRY_DELAY_MS = 1000
* Exchanges the refresh token for a new access token via Google's OAuth endpoint.
* function calculateRetryDelay(attempt: number): number {
* @param refreshToken - The refresh token to use return Math.min(INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt), 10000)
* @param clientId - Optional custom client ID (defaults to ANTIGRAVITY_CLIENT_ID) }
* @param clientSecret - Optional custom client secret (defaults to ANTIGRAVITY_CLIENT_SECRET)
* @returns Token exchange result with new access token, or throws on error function isRetryableError(status: number): boolean {
*/ if (status === 0) return true
if (status === 429) return true
if (status >= 500 && status < 600) return true
return false
}
export async function refreshAccessToken( export async function refreshAccessToken(
refreshToken: string, refreshToken: string,
clientId: string = ANTIGRAVITY_CLIENT_ID, clientId: string = ANTIGRAVITY_CLIENT_ID,
@@ -52,6 +100,10 @@ export async function refreshAccessToken(
client_secret: clientSecret, client_secret: clientSecret,
}) })
let lastError: AntigravityTokenRefreshError | undefined
for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) {
try {
const response = await fetch(GOOGLE_TOKEN_URL, { const response = await fetch(GOOGLE_TOKEN_URL, {
method: "POST", method: "POST",
headers: { headers: {
@@ -60,13 +112,7 @@ export async function refreshAccessToken(
body: params, body: params,
}) })
if (!response.ok) { if (response.ok) {
const errorText = await response.text().catch(() => "Unknown error")
throw new Error(
`Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`
)
}
const data = (await response.json()) as { const data = (await response.json()) as {
access_token: string access_token: string
refresh_token?: string refresh_token?: string
@@ -76,11 +122,59 @@ export async function refreshAccessToken(
return { return {
access_token: data.access_token, access_token: data.access_token,
// Google may return a new refresh token, fall back to the original
refresh_token: data.refresh_token || refreshToken, refresh_token: data.refresh_token || refreshToken,
expires_in: data.expires_in, expires_in: data.expires_in,
token_type: data.token_type, token_type: data.token_type,
} }
}
const responseBody = await response.text().catch(() => undefined)
const parsed = parseOAuthErrorPayload(responseBody)
lastError = new AntigravityTokenRefreshError({
message: parsed.description || `Token refresh failed: ${response.status} ${response.statusText}`,
code: parsed.code,
description: parsed.description,
status: response.status,
statusText: response.statusText,
responseBody,
})
if (parsed.code === "invalid_grant") {
throw lastError
}
if (!isRetryableError(response.status)) {
throw lastError
}
if (attempt < MAX_REFRESH_RETRIES) {
const delay = calculateRetryDelay(attempt)
await new Promise((resolve) => setTimeout(resolve, delay))
}
} catch (error) {
if (error instanceof AntigravityTokenRefreshError) {
throw error
}
lastError = new AntigravityTokenRefreshError({
message: error instanceof Error ? error.message : "Network error during token refresh",
status: 0,
statusText: "Network Error",
})
if (attempt < MAX_REFRESH_RETRIES) {
const delay = calculateRetryDelay(attempt)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
}
throw lastError || new AntigravityTokenRefreshError({
message: "Token refresh failed after all retries",
status: 0,
statusText: "Max Retries Exceeded",
})
} }
/** /**

View File

@@ -194,3 +194,20 @@ export interface AntigravityRefreshParts {
projectId?: string projectId?: string
managedProjectId?: string managedProjectId?: string
} }
/**
* OAuth error payload from Google
* Google returns errors in multiple formats, this handles all of them
*/
export interface OAuthErrorPayload {
error?: string | { status?: string; code?: string; message?: string }
error_description?: string
}
/**
* Parsed OAuth error with normalized fields
*/
export interface ParsedOAuthError {
code?: string
description?: string
}

View File

@@ -26,6 +26,7 @@ const TOKEN_LIMIT_KEYWORDS = [
"context length", "context length",
"too many tokens", "too many tokens",
"non-empty content", "non-empty content",
"invalid_request_error",
] ]
const MESSAGE_INDEX_PATTERN = /messages\.(\d+)/ const MESSAGE_INDEX_PATTERN = /messages\.(\d+)/
@@ -114,9 +115,10 @@ export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitErr
if (typeof responseBody === "string") { if (typeof responseBody === "string") {
try { try {
const jsonPatterns = [ const jsonPatterns = [
/data:\s*(\{[\s\S]*?\})\s*$/m, // Greedy match to last } for nested JSON
/(\{"type"\s*:\s*"error"[\s\S]*?\})/, /data:\s*(\{[\s\S]*\})\s*$/m,
/(\{[\s\S]*?"error"[\s\S]*?\})/, /(\{"type"\s*:\s*"error"[\s\S]*\})/,
/(\{[\s\S]*"error"[\s\S]*\})/,
] ]
for (const pattern of jsonPatterns) { for (const pattern of jsonPatterns) {