/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { ErrorInfo, GoogleApiError, QuotaFailure, RetryInfo, } from './googleErrors.js'; import { parseGoogleApiError } from './googleErrors.js'; /** * A non-retryable error indicating a hard quota limit has been reached (e.g., daily limit). */ export class TerminalQuotaError extends Error { constructor( message: string, override readonly cause: GoogleApiError, ) { super(message); this.name = 'TerminalQuotaError'; } } /** * A retryable error indicating a temporary quota issue (e.g., per-minute limit). */ export class RetryableQuotaError extends Error { retryDelayMs: number; constructor( message: string, override readonly cause: GoogleApiError, retryDelaySeconds: number, ) { super(message); this.name = 'RetryableQuotaError'; this.retryDelayMs = retryDelaySeconds * 1000; } } /** * Parses a duration string (e.g., "34.074824224s", "60s") and returns the time in seconds. * @param duration The duration string to parse. * @returns The duration in seconds, or null if parsing fails. */ function parseDurationInSeconds(duration: string): number | null { if (!duration.endsWith('s')) { return null; } const seconds = parseFloat(duration.slice(0, -1)); return isNaN(seconds) ? null : seconds; } /** * Analyzes a caught error and classifies it as a specific quota-related error if applicable. * * It decides whether an error is a `TerminalQuotaError` or a `RetryableQuotaError` based on * the following logic: * - If the error indicates a daily limit, it's a `TerminalQuotaError`. * - If the error suggests a retry delay of more than 2 minutes, it's a `TerminalQuotaError`. * - If the error suggests a retry delay of 2 minutes or less, it's a `RetryableQuotaError`. * - If the error indicates a per-minute limit, it's a `RetryableQuotaError`. * * @param error The error to classify. * @returns A `TerminalQuotaError`, `RetryableQuotaError`, or the original `unknown` error. */ export function classifyGoogleError(error: unknown): unknown { const googleApiError = parseGoogleApiError(error); if (!googleApiError || googleApiError.code !== 429) { return error; // Not a 429 error we can handle. } const quotaFailure = googleApiError.details.find( (d): d is QuotaFailure => d['@type'] === 'type.googleapis.com/google.rpc.QuotaFailure', ); const errorInfo = googleApiError.details.find( (d): d is ErrorInfo => d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo', ); const retryInfo = googleApiError.details.find( (d): d is RetryInfo => d['@type'] === 'type.googleapis.com/google.rpc.RetryInfo', ); // 1. Check for long-term limits in QuotaFailure or ErrorInfo if (quotaFailure) { for (const violation of quotaFailure.violations) { const quotaId = violation.quotaId ?? ''; if (quotaId.includes('PerDay') || quotaId.includes('Daily')) { return new TerminalQuotaError( `You have exhausted your daily quota on this model.`, googleApiError, ); } } } if (errorInfo) { // New Cloud Code API quota handling if (errorInfo.domain) { const validDomains = [ 'cloudcode-pa.googleapis.com', 'staging-cloudcode-pa.googleapis.com', 'autopush-cloudcode-pa.googleapis.com', ]; if (validDomains.includes(errorInfo.domain)) { if (errorInfo.reason === 'RATE_LIMIT_EXCEEDED') { let delaySeconds = 10; // Default retry of 10s if (retryInfo?.retryDelay) { const parsedDelay = parseDurationInSeconds(retryInfo.retryDelay); if (parsedDelay) { delaySeconds = parsedDelay; } } return new RetryableQuotaError( `${googleApiError.message}`, googleApiError, delaySeconds, ); } if (errorInfo.reason === 'QUOTA_EXHAUSTED') { return new TerminalQuotaError( `${googleApiError.message}`, googleApiError, ); } } } // Existing Cloud Code API quota handling const quotaLimit = errorInfo.metadata?.['quota_limit'] ?? ''; if (quotaLimit.includes('PerDay') || quotaLimit.includes('Daily')) { return new TerminalQuotaError( `You have exhausted your daily quota on this model.`, googleApiError, ); } } // 2. Check for long delays in RetryInfo if (retryInfo?.retryDelay) { const delaySeconds = parseDurationInSeconds(retryInfo.retryDelay); if (delaySeconds) { if (delaySeconds > 120) { return new TerminalQuotaError( `${googleApiError.message}\nSuggested retry after ${retryInfo.retryDelay}.`, googleApiError, ); } // This is a retryable error with a specific delay. return new RetryableQuotaError( `${googleApiError.message}\nSuggested retry after ${retryInfo.retryDelay}.`, googleApiError, delaySeconds, ); } } // 3. Check for short-term limits in QuotaFailure or ErrorInfo if (quotaFailure) { for (const violation of quotaFailure.violations) { const quotaId = violation.quotaId ?? ''; if (quotaId.includes('PerMinute')) { return new RetryableQuotaError( `${googleApiError.message}\nSuggested retry after 60s.`, googleApiError, 60, ); } } } if (errorInfo) { const quotaLimit = errorInfo.metadata?.['quota_limit'] ?? ''; if (quotaLimit.includes('PerMinute')) { return new RetryableQuotaError( `${errorInfo.reason}\nSuggested retry after 60s.`, googleApiError, 60, ); } } return error; // Fallback to original error if no specific classification fits. }