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:
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user