feat(cloud-oauth): support selectable google oauth clients

This commit is contained in:
Draculabo committed 2026-04-11 16:45:39 +08:00
1 parent 385036d577
commit bcdf2a6c24
8 files changed
+1370 -125

No files matched your search

+530 -91
View File
@@ -1,33 +1,207 @@
import { z } from 'zod';
import { ConfigManager } from '../ipc/config/manager';
import { AuthServer } from '../ipc/cloud/authServer';
import { ProxyAgent } from 'undici';
import { EnvHttpProxyAgent, ProxyAgent } from 'undici';
import { logger } from '../utils/logger';
import {
buildUserAgent,
FALLBACK_VERSION,
resolveLocalInstalledVersion,
} from '@/server/modules/proxy/request-user-agent';
import { isNumber, isString } from 'lodash-es';
// --- Constants & Config ---
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
const CLIENT_SECRET = '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';
const OAUTH_CLIENTS_ENV = 'ANTIGRAVITY_OAUTH_CLIENTS';
const ACTIVE_OAUTH_CLIENT_ENV = 'ANTIGRAVITY_OAUTH_CLIENT_KEY';
const DEFAULT_OAUTH_CLIENT_KEY = 'antigravity_enterprise';
const URLS = {
TOKEN: 'https://oauth2.googleapis.com/token',
USER_INFO: 'https://www.googleapis.com/oauth2/v2/userinfo',
AUTH: 'https://accounts.google.com/o/oauth2/v2/auth',
QUOTA: 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
LOAD_PROJECT: 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
DAILY_LOAD_PROJECT: 'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
FETCH_CREDITS: 'https://cloudcode-pa.googleapis.com/v1internal:fetchCredits',
};
const QUOTA_API_ENDPOINTS = [
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
] as const;
// Internal API masquerading
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64';
// Request timeout in milliseconds (30 seconds)
const REQUEST_TIMEOUT_MS = 30000;
interface OAuthClientConfig {
key: string;
label: string;
client_id: string;
client_secret: string;
is_builtin: boolean;
}
interface OAuthClientRegistry {
clients: OAuthClientConfig[];
activeKey: string;
}
let cachedOAuthClientRegistry: OAuthClientRegistry | null = null;
function normalizeClientKey(key: string): string {
return key.trim().toLowerCase();
}
function getClientByKey(
clients: OAuthClientConfig[],
clientKey: string | undefined,
): OAuthClientConfig | null {
if (!clientKey) {
return null;
}
const normalizedKey = normalizeClientKey(clientKey);
return clients.find((client) => client.key === normalizedKey) ?? null;
}
function buildOAuthClientRegistry(): OAuthClientRegistry {
const clients: OAuthClientConfig[] = [
{
key: normalizeClientKey(DEFAULT_OAUTH_CLIENT_KEY),
label: 'Antigravity Enterprise',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
is_builtin: true,
},
];
const rawExtraClients = process.env[OAUTH_CLIENTS_ENV];
if (isString(rawExtraClients) && rawExtraClients.trim() !== '') {
for (const entry of rawExtraClients.split(';')) {
const trimmed = entry.trim();
if (trimmed === '') {
continue;
}
const parts = trimmed.split('|').map((part) => part.trim());
if (parts.length < 3) {
logger.warn(
`[GoogleAPIService] Ignored invalid OAuth client entry in ${OAUTH_CLIENTS_ENV}: ${trimmed}`,
);
continue;
}
const key = normalizeClientKey(parts[0]);
const clientId = parts[1];
const clientSecret = parts[2];
if (key === '' || clientId === '' || clientSecret === '') {
logger.warn(
`[GoogleAPIService] Ignored incomplete OAuth client entry in ${OAUTH_CLIENTS_ENV}: ${trimmed}`,
);
continue;
}
const clientConfig: OAuthClientConfig = {
key,
label: parts[3] && parts[3] !== '' ? parts[3] : key,
client_id: clientId,
client_secret: clientSecret,
is_builtin: false,
};
const existingIndex = clients.findIndex((client) => client.key === key);
if (existingIndex >= 0) {
clients[existingIndex] = clientConfig;
} else {
clients.push(clientConfig);
}
}
}
let activeKey = normalizeClientKey(
process.env[ACTIVE_OAUTH_CLIENT_ENV] || DEFAULT_OAUTH_CLIENT_KEY,
);
if (!clients.some((client) => client.key === activeKey)) {
activeKey = clients[0]?.key ?? normalizeClientKey(DEFAULT_OAUTH_CLIENT_KEY);
}
return {
clients,
activeKey,
};
}
function getOAuthClientRegistry(): OAuthClientRegistry {
if (cachedOAuthClientRegistry) {
return cachedOAuthClientRegistry;
}
cachedOAuthClientRegistry = buildOAuthClientRegistry();
return cachedOAuthClientRegistry;
}
function getCandidateClients(preferredClientKey?: string): OAuthClientConfig[] {
const registry = getOAuthClientRegistry();
const candidates: OAuthClientConfig[] = [];
const seen = new Set<string>();
const pushCandidate = (candidate: OAuthClientConfig | null) => {
if (!candidate || seen.has(candidate.key)) {
return;
}
seen.add(candidate.key);
candidates.push(candidate);
};
const preferred = getClientByKey(registry.clients, preferredClientKey);
if (preferredClientKey && !preferred) {
logger.warn(
`[GoogleAPIService] Preferred OAuth client '${preferredClientKey}' not found; fallback to active client list`,
);
}
pushCandidate(preferred);
pushCandidate(getClientByKey(registry.clients, registry.activeKey));
for (const client of registry.clients) {
pushCandidate(client);
}
return candidates;
}
function selectAuthClient(clientKey?: string): OAuthClientConfig {
const registry = getOAuthClientRegistry();
if (registry.clients.length === 0) {
throw new Error('No OAuth clients configured');
}
if (clientKey && clientKey.trim() !== '') {
const selected = getClientByKey(registry.clients, clientKey);
if (!selected) {
throw new Error(`Unknown OAuth client key: ${clientKey}`);
}
return selected;
}
return getClientByKey(registry.clients, registry.activeKey) ?? registry.clients[0];
}
function isClientMismatchError(status: number, errorText: string): boolean {
const text = errorText.toLowerCase();
return (
status === 400 ||
status === 401 ||
status === 403 ||
text.includes('unauthorized_client') ||
text.includes('invalid_client')
);
}
/**
* Creates an AbortSignal that times out after the specified duration.
*/
@@ -45,6 +219,15 @@ export interface TokenResponse {
token_type: string;
refresh_token?: string;
scope?: string;
oauth_client_key?: string;
}
export interface OAuthClientDescriptor {
key: string;
label: string;
client_id: string;
is_active: boolean;
is_builtin: boolean;
}
export interface UserInfo {
@@ -52,19 +235,19 @@ export interface UserInfo {
email: string;
verified_email: boolean;
name: string;
given_name: string;
family_name: string;
picture: string;
given_name?: string;
family_name?: string;
picture?: string;
}
export const UserInfoSchema = z.object({
id: z.string(),
email: z.string(),
verified_email: z.boolean(),
name: z.string(),
given_name: z.string(),
family_name: z.string(),
picture: z.string(),
verified_email: z.boolean().optional().default(false),
name: z.string().optional(),
given_name: z.string().optional(),
family_name: z.string().optional(),
picture: z.string().optional(),
});
export interface QuotaData {
@@ -118,6 +301,13 @@ interface TierRaw {
quotaTier?: string;
name?: string;
slug?: string;
availableCredits?: AvailableCreditRaw[];
}
interface AvailableCreditRaw {
creditType?: string;
creditAmount?: string | number;
minimumCreditAmountForUsage?: string | number;
}
interface LoadProjectResponse {
@@ -228,11 +418,129 @@ function toModelForwardingRules(
return Object.keys(forwardingRules).length > 0 ? forwardingRules : undefined;
}
function parseCreditAmount(value: string | number | undefined): number {
if (isNumber(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return 0;
}
function toAiCredits(
payload: Partial<{
credits: unknown;
remainingCredits: unknown;
expiryDate: unknown;
expirationDate: unknown;
}>,
): { credits: number; expiryDate: string } | null {
const creditsValue =
isNumber(payload.credits) || isString(payload.credits)
? payload.credits
: isNumber(payload.remainingCredits) || isString(payload.remainingCredits)
? payload.remainingCredits
: undefined;
if (typeof creditsValue === 'undefined') {
return null;
}
const expiryDate = isString(payload.expiryDate)
? payload.expiryDate
: isString(payload.expirationDate)
? payload.expirationDate
: '';
return {
credits: parseCreditAmount(creditsValue),
expiryDate,
};
}
function extractAiCreditsFromProjectContext(
payload: LoadProjectResponse,
): { credits: number; expiryDate: string } | null {
const availableCredit = payload.paidTier?.availableCredits?.[0];
if (!availableCredit) {
return null;
}
return {
credits: parseCreditAmount(availableCredit.creditAmount),
expiryDate: '',
};
}
// --- Service Implementation ---
export class GoogleAPIService {
static listOAuthClients(): OAuthClientDescriptor[] {
const registry = getOAuthClientRegistry();
return registry.clients.map((client) => {
return {
key: client.key,
label: client.label,
client_id: client.client_id,
is_active: client.key === registry.activeKey,
is_builtin: client.is_builtin,
};
});
}
static getActiveOAuthClientKey(): string {
const registry = getOAuthClientRegistry();
return registry.activeKey;
}
static setActiveOAuthClientKey(clientKey: string): void {
const registry = getOAuthClientRegistry();
const normalized = normalizeClientKey(clientKey);
const exists = registry.clients.some((client) => client.key === normalized);
if (!exists) {
const available = registry.clients.map((client) => client.key).join(', ');
throw new Error(`Unknown OAuth client key '${clientKey}'. Available: ${available}`);
}
registry.activeKey = normalized;
process.env[ACTIVE_OAUTH_CLIENT_ENV] = normalized;
}
static normalizeRefreshedOAuthClientKey(
currentToken: { oauth_client_key?: string; project_id?: string },
refreshedClientKey?: string,
): string | undefined {
const resolved = refreshedClientKey ?? currentToken.oauth_client_key;
const projectMissing =
typeof currentToken.project_id !== 'string' || currentToken.project_id.trim() === '';
if (
typeof currentToken.oauth_client_key !== 'string' &&
projectMissing &&
resolved &&
normalizeClientKey(resolved) === DEFAULT_OAUTH_CLIENT_KEY
) {
logger.warn(
'[GoogleAPIService] Refreshed token via enterprise client for a legacy account without project_id; keep oauth_client_key unset to avoid accidental enterprise lock',
);
return undefined;
}
return resolved ? normalizeClientKey(resolved) : undefined;
}
private static getFetchOptions(proxyUrl?: string) {
const proxyTraceEnabled = process.env.DEBUG_PROXY_TRACE === '1';
if (proxyUrl && proxyUrl.length > 0) {
if (proxyTraceEnabled) {
logger.info('[GoogleAPIService] Proxy source: account proxy_url');
}
return {
dispatcher: new ProxyAgent(proxyUrl),
};
@@ -243,6 +551,9 @@ export class GoogleAPIService {
if (!config.proxy.upstream_proxy.url) {
throw new Error('Upstream proxy is enabled but URL is not configured');
}
if (proxyTraceEnabled) {
logger.info('[GoogleAPIService] Proxy source: config.proxy.upstream_proxy.url');
}
return {
dispatcher: new ProxyAgent(config.proxy.upstream_proxy.url),
};
@@ -251,23 +562,62 @@ export class GoogleAPIService {
logger.error('[GoogleAPIService] Proxy configuration error', e);
throw e;
}
const httpProxy = process.env.http_proxy?.trim() || process.env.HTTP_PROXY?.trim();
const httpsProxy = process.env.https_proxy?.trim() || process.env.HTTPS_PROXY?.trim();
const noProxy = process.env.no_proxy?.trim() || process.env.NO_PROXY?.trim();
const electronProxyServer = process.env.ELECTRON_PROXY_SERVER?.trim();
if (httpProxy || httpsProxy) {
if (proxyTraceEnabled) {
logger.info(
`[GoogleAPIService] Proxy source: HTTP(S)_PROXY env (http: ${httpProxy ?? 'none'}, https: ${httpsProxy ?? 'none'})`,
);
}
return {
dispatcher: new EnvHttpProxyAgent({
httpProxy,
httpsProxy,
noProxy,
}),
};
}
if (electronProxyServer) {
if (proxyTraceEnabled) {
logger.info(
`[GoogleAPIService] Proxy source: ELECTRON_PROXY_SERVER env (${electronProxyServer})`,
);
}
return {
dispatcher: new ProxyAgent(electronProxyServer),
};
}
if (proxyTraceEnabled) {
logger.info('[GoogleAPIService] Proxy source: none');
}
return {};
}
/**
* Generates the OAuth2 authorization URL.
*/
static getAuthUrl(): string {
static getAuthUrl(oauthClientKey?: string): string {
const scopes = [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/cclog',
'https://www.googleapis.com/auth/experimentsandconfigs',
].join(' ');
const oauthClient = selectAuthClient(oauthClientKey);
const redirectUri = AuthServer.getRedirectUri();
const params = new URLSearchParams({
client_id: CLIENT_ID,
client_id: oauthClient.client_id,
redirect_uri: redirectUri,
response_type: 'code',
scope: scopes,
@@ -282,78 +632,121 @@ export class GoogleAPIService {
/**
* Exchanges an authorization code for tokens.
*/
static async exchangeCode(code: string, proxyUrl?: string): Promise<TokenResponse> {
static async exchangeCode(
code: string,
proxyUrl?: string,
preferredClientKey?: string,
): Promise<TokenResponse> {
const redirectUri = AuthServer.getRedirectUri();
const candidates = getCandidateClients(preferredClientKey);
if (candidates.length === 0) {
throw new Error('No OAuth clients configured');
}
const params = new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code: code,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
});
const attemptErrors: string[] = [];
const response = await fetch(URLS.TOKEN, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...this.getFetchOptions(proxyUrl),
}).catch((err: unknown) => {
if (err instanceof Error) {
if (err.name === 'AbortError') {
for (const client of candidates) {
const params = new URLSearchParams({
client_id: client.client_id,
client_secret: client.client_secret,
code,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
});
const response = await fetch(URLS.TOKEN, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...this.getFetchOptions(proxyUrl),
}).catch((err: unknown) => {
if (err instanceof Error && err.name === 'AbortError') {
throw new Error(
'Token exchange timed out. Please check your network connection and try again.',
);
}
throw err;
});
if (response.ok) {
const tokenResponse = (await response.json()) as TokenResponse;
tokenResponse.oauth_client_key = client.key;
return tokenResponse;
}
throw err;
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Token exchange failed: ${text}`);
attemptErrors.push(`${client.key} => ${text}`);
if (isClientMismatchError(response.status, text)) {
logger.warn(
`[GoogleAPIService] Token exchange failed for OAuth client '${client.key}', trying next client`,
);
continue;
}
throw new Error(`Token exchange failed for client [${client.key}]: ${text}`);
}
return response.json() as Promise<TokenResponse>;
throw new Error(`Token exchange failed for all OAuth clients: ${attemptErrors.join(' | ')}`);
}
/**
* Refreshes an access token using a refresh token.
*/
static async refreshAccessToken(refreshToken: string, proxyUrl?: string): Promise<TokenResponse> {
const params = new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: 'refresh_token',
});
static async refreshAccessToken(
refreshToken: string,
proxyUrl?: string,
preferredClientKey?: string,
): Promise<TokenResponse> {
const candidates = getCandidateClients(preferredClientKey);
if (candidates.length === 0) {
throw new Error('No OAuth clients configured');
}
const response = await fetch(URLS.TOKEN, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...this.getFetchOptions(proxyUrl),
}).catch((err: unknown) => {
if (err instanceof Error) {
if (err.name === 'AbortError') {
const attemptErrors: string[] = [];
for (const client of candidates) {
const params = new URLSearchParams({
client_id: client.client_id,
client_secret: client.client_secret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
});
const response = await fetch(URLS.TOKEN, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...this.getFetchOptions(proxyUrl),
}).catch((err: unknown) => {
if (err instanceof Error && err.name === 'AbortError') {
throw new Error(
'Token refresh timed out. Please check your network connection and try again.',
);
}
throw err;
});
if (response.ok) {
const tokenResponse = (await response.json()) as TokenResponse;
tokenResponse.oauth_client_key = client.key;
return tokenResponse;
}
throw err;
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Token refresh failed: ${text}`);
attemptErrors.push(`${client.key} => ${text}`);
if (isClientMismatchError(response.status, text)) {
logger.warn(
`[GoogleAPIService] Token refresh failed for OAuth client '${client.key}', trying next client`,
);
continue;
}
throw new Error(`Token refresh failed for client [${client.key}]: ${text}`);
}
const data = (await response.json()) as TokenResponse;
return data;
throw new Error(`Token refresh failed for all OAuth clients: ${attemptErrors.join(' | ')}`);
}
/**
@@ -382,7 +775,13 @@ export class GoogleAPIService {
const data = await response.json();
try {
return UserInfoSchema.parse(data);
const parsed = UserInfoSchema.parse(data);
return {
...parsed,
// Google may omit profile claims such as family_name for accounts with limited profile data.
name: parsed.name ?? parsed.email,
};
} catch (err) {
logger.error('[GoogleAPIService] Malformed user info response:', err);
throw new Error('Received malformed user info from Google APIs');
@@ -451,20 +850,55 @@ export class GoogleAPIService {
proxyUrl?: string,
): Promise<{ credits: number; expiryDate: string } | null> {
try {
const response = await fetch('https://cloudcode-pa.googleapis.com/v1internal:fetchCredits', {
const fetchOptions = this.getFetchOptions(proxyUrl);
const response = await fetch(URLS.FETCH_CREDITS, {
method: 'POST',
headers: buildInternalApiHeaders(accessToken),
body: JSON.stringify({}),
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...this.getFetchOptions(proxyUrl),
...fetchOptions,
});
if (!response.ok) return null;
const data = await response.json();
return {
credits: data?.credits ?? data?.remainingCredits ?? 0,
expiryDate: data?.expiryDate ?? data?.expirationDate ?? '',
};
} catch {
if (response.ok) {
const data = (await response.json()) as Partial<{
credits: number | string;
remainingCredits: number | string;
expiryDate: string;
expirationDate: string;
}>;
return toAiCredits(data);
}
if (response.status !== 404) {
return null;
}
// `fetchCredits` has started returning 404 for some accounts. Reuse the
// internal `loadCodeAssist` payload, which also exposes paid tier credits.
logger.warn('[GoogleAPIService] fetchCredits returned 404, falling back to loadCodeAssist');
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
const fallbackResponse = await fetch(URLS.DAILY_LOAD_PROJECT, {
method: 'POST',
headers: buildInternalApiHeaders(accessToken),
body: JSON.stringify({
metadata: {
ide_type: 'ANTIGRAVITY',
ide_version: discoveryVersion,
ide_name: 'antigravity',
},
}),
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...fetchOptions,
});
if (!fallbackResponse.ok) {
return null;
}
const fallbackData = (await fallbackResponse.json()) as LoadProjectResponse;
return extractAiCreditsFromProjectContext(fallbackData);
} catch (e) {
return null;
}
}
@@ -475,18 +909,16 @@ export class GoogleAPIService {
static async fetchQuota(accessToken: string, proxyUrl?: string): Promise<QuotaData> {
const { projectId, subscriptionTier } = await this.fetchProjectContext(accessToken, proxyUrl);
const payload: Record<string, unknown> = {};
if (projectId) {
payload['project'] = projectId;
}
const maxRetries = 3;
const payload: Record<string, unknown> = projectId ? { project: projectId } : {};
let lastError: Error | null = null;
const fetchOptions = this.getFetchOptions(proxyUrl);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
for (let endpointIndex = 0; endpointIndex < QUOTA_API_ENDPOINTS.length; endpointIndex++) {
const endpoint = QUOTA_API_ENDPOINTS[endpointIndex];
const hasNextEndpoint = endpointIndex + 1 < QUOTA_API_ENDPOINTS.length;
try {
const response = await fetch(URLS.QUOTA, {
const response = await fetch(endpoint, {
method: 'POST',
headers: buildInternalApiHeaders(accessToken),
body: JSON.stringify(payload),
@@ -506,17 +938,17 @@ export class GoogleAPIService {
}
const errorMsg = `HTTP ${status} - ${text}`;
logger.warn(
`[GoogleAPIService] API Error: ${errorMsg} (Attempt ${attempt}/${maxRetries})`,
);
if (hasNextEndpoint && (status === 429 || status >= 500)) {
logger.warn(
`[GoogleAPIService] Quota API ${endpoint} returned ${status}, falling back to next endpoint`,
);
lastError = new Error(errorMsg);
if (attempt < maxRetries) {
const backoffMs = 1000 * Math.pow(2, attempt - 1);
await sleep(backoffMs);
await sleep(1000);
continue;
} else {
throw new Error(errorMsg);
}
throw new Error(errorMsg);
}
const data = (await response.json()) as FetchModelsResponse;
@@ -538,26 +970,33 @@ export class GoogleAPIService {
result.model_forwarding_rules = modelForwardingRules;
}
if (endpointIndex > 0) {
logger.info(
`[GoogleAPIService] Quota API fallback succeeded at endpoint #${endpointIndex + 1}`,
);
}
return result;
} catch (error: unknown) {
const errorMsg = error instanceof Error ? error.message : String(error);
if (error instanceof Error) {
logger.warn(
`[GoogleAPIService] Request failed: ${error.message} (Attempt ${attempt}/${maxRetries})`,
);
}
lastError = error instanceof Error ? error : new Error(String(error));
const isPermanentHttp4xx =
/^HTTP 4\d{2}\b/.test(errorMsg) && !errorMsg.startsWith('HTTP 429');
// Abort retries for auth errors
if (errorMsg === 'FORBIDDEN' || errorMsg === 'UNAUTHORIZED') {
throw error;
}
if (attempt < maxRetries) {
const backoffMs = 1000 * Math.pow(2, attempt - 1);
await sleep(backoffMs);
if (hasNextEndpoint && !isPermanentHttp4xx) {
logger.warn(
`[GoogleAPIService] Quota API request failed at ${endpoint}: ${errorMsg}. Falling back to next endpoint`,
);
await sleep(1000);
continue;
}
throw lastError;
}
}