UNPKG

@jaimeflneto/n8n-nodes-google-ads-conversion

Version:

n8n node for tracking conversions in Google Ads with support for batch processing, enhanced conversions, and comprehensive privacy compliance

1,109 lines 119 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.GoogleAdsConversion = void 0; const n8n_workflow_1 = require("n8n-workflow"); const crypto_1 = require("crypto"); const GOOGLE_ADS_API_VERSION = 'v23'; const GOOGLE_ADS_API_BASE_URL = `https://googleads.googleapis.com/${GOOGLE_ADS_API_VERSION}`; // Custom error classes for better error categorization class GoogleAdsAuthenticationError extends n8n_workflow_1.NodeOperationError { constructor(node, message) { super(node, `Authentication Error: ${message}`); this.name = 'GoogleAdsAuthenticationError'; } } class GoogleAdsValidationError extends n8n_workflow_1.NodeOperationError { constructor(node, message, field) { const fieldInfo = field ? ` (Field: ${field})` : ''; super(node, `Validation Error: ${message}${fieldInfo}`); this.name = 'GoogleAdsValidationError'; } } class GoogleAdsApiError extends n8n_workflow_1.NodeOperationError { constructor(node, message, httpCode, apiErrorCode) { super(node, `Google Ads API Error: ${message}`); this.name = 'GoogleAdsApiError'; this.httpCode = httpCode; this.apiErrorCode = apiErrorCode; } } class GoogleAdsRateLimitError extends n8n_workflow_1.NodeOperationError { constructor(node, message, retryAfter) { super(node, `Rate Limit Error: ${message}`); this.name = 'GoogleAdsRateLimitError'; this.retryAfter = retryAfter; } } class GoogleAdsConversion { constructor() { this.description = { displayName: 'Google Ads Conversion', name: 'googleAdsConversion', icon: 'file:googleAds.svg', group: ['output'], version: 1, subtitle: '={{$parameter["operation"] + ": " + ($parameter["conversionAction"].value || "Not configured")}}', description: 'Send conversion events to Google Ads for campaign optimization', defaults: { name: 'Google Ads Conversion', }, // @ts-ignore - Compatibility with different n8n versions inputs: ['main'], // @ts-ignore - Compatibility with different n8n versions outputs: ['main'], credentials: [ { name: 'googleAdsOAuth2', required: true, }, ], requestDefaults: { baseURL: GOOGLE_ADS_API_BASE_URL, headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, }, properties: [ { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, options: [ { name: 'Upload Click Conversion', value: 'uploadClickConversion', description: 'Upload a click conversion to Google Ads', action: 'Upload a click conversion', }, ], default: 'uploadClickConversion', }, // Account Type Detection { displayName: 'Account Type', name: 'accountType', type: 'options', options: [ { name: 'Regular Google Ads Account', value: 'regular', description: 'Direct Google Ads account (not a manager account)', }, { name: 'Manager Account (MCC)', value: 'manager', description: 'Manager account that manages multiple client accounts', }, ], default: 'regular', description: 'Select your account type', displayOptions: { show: { operation: ['uploadClickConversion'], }, }, }, { displayName: 'Managed Account', name: 'managedAccount', type: 'resourceLocator', default: { mode: 'list', value: '' }, required: true, description: 'Select the managed account to upload conversions to', modes: [ { displayName: 'From List', name: 'list', type: 'list', placeholder: 'Select a managed account...', typeOptions: { searchListMethod: 'getManagedAccounts', searchable: true, }, }, { displayName: 'By ID', name: 'id', type: 'string', validation: [ { type: 'regex', properties: { regex: '^[0-9]+$', errorMessage: 'Customer ID must contain only numbers', }, }, ], placeholder: '1234567890', }, ], displayOptions: { show: { operation: ['uploadClickConversion'], accountType: ['manager'], }, }, }, // Conversion Data Section { displayName: 'Conversion Data', name: 'conversionDataSection', type: 'notice', default: '', displayOptions: { show: { operation: ['uploadClickConversion'], }, }, }, { displayName: 'Conversion Action', name: 'conversionAction', type: 'resourceLocator', required: true, default: { mode: 'list', value: '' }, description: 'The conversion action from your Google Ads account', modes: [ { displayName: 'From List', name: 'list', type: 'list', placeholder: 'Select a conversion action...', typeOptions: { searchListMethod: 'getConversionActions', searchable: true, }, }, { displayName: 'By ID', name: 'id', type: 'string', placeholder: 'customers/{customer_id}/conversionActions/{conversion_action_id}', hint: 'Enter the full resource name or just the conversion action ID', }, ], displayOptions: { show: { operation: ['uploadClickConversion'], }, }, }, { displayName: 'Conversion Date Time', name: 'conversionDateTime', type: 'string', required: true, default: '={{$now}}', description: 'The date and time of the conversion. Accepts DateTime objects (like $now) or strings in YYYY-MM-DD HH:MM:SS+TZ format', hint: 'Example: 2024-01-15 14:30:00+00:00 or use {{$now}} for current time', displayOptions: { show: { operation: ['uploadClickConversion'], }, }, }, { displayName: 'Conversion Value', name: 'conversionValue', type: 'number', default: 0, description: 'The value of the conversion (optional)', displayOptions: { show: { operation: ['uploadClickConversion'], }, }, }, { displayName: 'Currency Code', name: 'currencyCode', type: 'string', default: 'USD', description: 'Three-letter ISO 4217 currency code', displayOptions: { show: { operation: ['uploadClickConversion'], }, }, }, { displayName: 'Order ID', name: 'orderId', type: 'string', default: '', description: 'Unique transaction/order identifier (optional but recommended)', displayOptions: { show: { operation: ['uploadClickConversion'], }, }, }, // User Identification Section { displayName: 'User Identification', name: 'userIdentificationSection', type: 'notice', default: '', displayOptions: { show: { operation: ['uploadClickConversion'], }, }, }, { displayName: 'Identification Method', name: 'identificationMethod', type: 'options', options: [ { name: 'GCLID (Google Click ID)', value: 'gclid', description: 'Use Google Click ID for identification', }, { name: 'GBRAID (iOS App Install)', value: 'gbraid', description: 'Use GBRAID for iOS app install conversions', }, { name: 'WBRAID (iOS Web-to-App)', value: 'wbraid', description: 'Use WBRAID for iOS web-to-app conversions', }, { name: 'Enhanced Conversions', value: 'enhanced', description: 'Use hashed user data for enhanced conversions', }, ], default: 'gclid', description: 'Select the method to identify the user who converted', displayOptions: { show: { operation: ['uploadClickConversion'], }, }, }, { displayName: 'GCLID', name: 'gclid', type: 'string', required: true, default: '', description: 'The Google Click ID from the ad click', displayOptions: { show: { operation: ['uploadClickConversion'], identificationMethod: ['gclid'], }, }, }, { displayName: 'GBRAID', name: 'gbraid', type: 'string', required: true, default: '', description: 'The GBRAID for iOS app install conversions', displayOptions: { show: { operation: ['uploadClickConversion'], identificationMethod: ['gbraid'], }, }, }, { displayName: 'WBRAID', name: 'wbraid', type: 'string', required: true, default: '', description: 'The WBRAID for iOS web-to-app conversions', displayOptions: { show: { operation: ['uploadClickConversion'], identificationMethod: ['wbraid'], }, }, }, // Enhanced Conversions Section { displayName: 'Enhanced Conversion Data', name: 'enhancedConversionSection', type: 'notice', default: '', displayOptions: { show: { operation: ['uploadClickConversion'], identificationMethod: ['enhanced'], }, }, }, { displayName: 'Email Address', name: 'email', type: 'string', default: '', description: 'User email address (will be automatically hashed)', displayOptions: { show: { operation: ['uploadClickConversion'], identificationMethod: ['enhanced'], }, }, }, { displayName: 'Phone Number', name: 'phoneNumber', type: 'string', default: '', description: 'User phone number in E.164 format (will be automatically hashed)', hint: 'Example: +1234567890', displayOptions: { show: { operation: ['uploadClickConversion'], identificationMethod: ['enhanced'], }, }, }, { displayName: 'First Name', name: 'firstName', type: 'string', default: '', description: 'User first name (will be automatically hashed)', displayOptions: { show: { operation: ['uploadClickConversion'], identificationMethod: ['enhanced'], }, }, }, { displayName: 'Last Name', name: 'lastName', type: 'string', default: '', description: 'User last name (will be automatically hashed)', displayOptions: { show: { operation: ['uploadClickConversion'], identificationMethod: ['enhanced'], }, }, }, { displayName: 'Street Address', name: 'streetAddress', type: 'string', default: '', description: 'User street address (will be automatically hashed)', displayOptions: { show: { operation: ['uploadClickConversion'], identificationMethod: ['enhanced'], }, }, }, { displayName: 'City', name: 'city', type: 'string', default: '', description: 'User city (will be automatically hashed)', displayOptions: { show: { operation: ['uploadClickConversion'], identificationMethod: ['enhanced'], }, }, }, { displayName: 'State/Region', name: 'state', type: 'string', default: '', description: 'User state or region (will be automatically hashed)', displayOptions: { show: { operation: ['uploadClickConversion'], identificationMethod: ['enhanced'], }, }, }, { displayName: 'Postal Code', name: 'postalCode', type: 'string', default: '', description: 'User postal/ZIP code (will be automatically hashed)', displayOptions: { show: { operation: ['uploadClickConversion'], identificationMethod: ['enhanced'], }, }, }, { displayName: 'Country Code', name: 'countryCode', type: 'string', default: '', description: 'Two-letter ISO 3166-1 alpha-2 country code (will be automatically hashed)', hint: 'Example: US, GB, DE', displayOptions: { show: { operation: ['uploadClickConversion'], identificationMethod: ['enhanced'], }, }, }, // Privacy Compliance Section { displayName: 'Privacy Compliance (EEA)', name: 'privacySection', type: 'notice', default: '', displayOptions: { show: { operation: ['uploadClickConversion'], }, }, }, { displayName: 'Ad User Data Consent', name: 'adUserDataConsent', type: 'options', options: [ { name: 'Granted', value: 'GRANTED', description: 'User has consented to ad user data usage', }, { name: 'Denied', value: 'DENIED', description: 'User has denied consent for ad user data usage', }, { name: 'Unknown', value: 'UNKNOWN', description: 'Consent status is unknown', }, ], default: 'UNKNOWN', description: 'User consent for ad user data usage (required for EEA)', displayOptions: { show: { operation: ['uploadClickConversion'], }, }, }, { displayName: 'Ad Personalization Consent', name: 'adPersonalizationConsent', type: 'options', options: [ { name: 'Granted', value: 'GRANTED', description: 'User has consented to ad personalization', }, { name: 'Denied', value: 'DENIED', description: 'User has denied consent for ad personalization', }, { name: 'Unknown', value: 'UNKNOWN', description: 'Consent status is unknown', }, ], default: 'UNKNOWN', description: 'User consent for ad personalization (required for EEA)', displayOptions: { show: { operation: ['uploadClickConversion'], }, }, }, // Debug Section { displayName: 'Advanced Options', name: 'advancedSection', type: 'notice', default: '', displayOptions: { show: { operation: ['uploadClickConversion'], }, }, }, { displayName: 'Validate Only', name: 'validateOnly', type: 'boolean', default: false, description: 'Only validate the conversion data without actually uploading', displayOptions: { show: { operation: ['uploadClickConversion'], }, }, }, { displayName: 'Debug Mode', name: 'debugMode', type: 'boolean', default: false, description: 'Enable debug mode for detailed logging and response data', displayOptions: { show: { operation: ['uploadClickConversion'], }, }, }, // Testing & Debug Section { displayName: 'Batch Processing', name: 'batchProcessingSection', type: 'notice', default: '', displayOptions: { show: { operation: ['uploadClickConversion'], }, }, }, { displayName: 'Enable Batch Processing', name: 'enableBatchProcessing', type: 'boolean', default: false, description: 'Process multiple conversions in batches for better performance', displayOptions: { show: { operation: ['uploadClickConversion'], }, }, }, { displayName: 'Batch Size', name: 'batchSize', type: 'number', default: 100, description: 'Number of conversions to process in each batch (max 2000)', typeOptions: { minValue: 1, maxValue: 2000, }, displayOptions: { show: { operation: ['uploadClickConversion'], enableBatchProcessing: [true], }, }, }, { displayName: 'Batch Processing Mode', name: 'batchProcessingMode', type: 'options', options: [ { name: 'Fail on First Error', value: 'failFast', description: 'Stop processing if any batch fails', }, { name: 'Continue on Errors', value: 'continueOnError', description: 'Continue processing even if some batches fail', }, { name: 'Partial Failure Mode', value: 'partialFailure', description: 'Use Google Ads partial failure policy (recommended)', }, ], default: 'partialFailure', description: 'How to handle errors during batch processing', displayOptions: { show: { operation: ['uploadClickConversion'], enableBatchProcessing: [true], }, }, }, { displayName: 'Show Progress', name: 'showProgress', type: 'boolean', default: true, description: 'Log batch processing progress', displayOptions: { show: { operation: ['uploadClickConversion'], enableBatchProcessing: [true], }, }, }, // Testing & Debug Section (existing) { displayName: 'Testing & Debug', name: 'testingSection', type: 'notice', default: '', displayOptions: { show: { operation: ['uploadClickConversion'], }, }, }, ], }; this.methods = { listSearch: { async getManagedAccounts() { try { console.log('getManagedAccounts: Starting request...'); // Get credentials and developer token from authentication const credentials = await this.getCredentials('googleAdsOAuth2'); if (!credentials) { console.error('getManagedAccounts: No credentials found'); throw new Error('No credentials provided for Google Ads OAuth2'); } const managerCustomerId = credentials.customerId; const developerToken = credentials.developerToken; console.log('getManagedAccounts: Credentials check:', { hasCustomerId: !!managerCustomerId, hasDeveloperToken: !!developerToken, customerIdLength: managerCustomerId?.length || 0, }); if (!managerCustomerId) { console.error('getManagedAccounts: Manager customer ID is missing'); throw new Error('Manager customer ID is required'); } if (!developerToken) { console.error('getManagedAccounts: Developer token is missing'); throw new Error('Developer token is required'); } const sanitizedManagerId = managerCustomerId.replace(/\D/g, ''); if (!sanitizedManagerId) { console.error('getManagedAccounts: Customer ID has no valid digits'); throw new Error('Customer ID must contain at least one digit'); } const apiUrl = `/customers/${sanitizedManagerId}/googleAds:search`; const baseUrl = GOOGLE_ADS_API_BASE_URL; const requestHeaders = { 'developer-token': developerToken, 'login-customer-id': sanitizedManagerId, Accept: 'application/json', 'Content-Type': 'application/json', }; const requestBody = { query: ` SELECT customer_client.client_customer, customer_client.descriptive_name, customer_client.currency_code, customer_client.time_zone, customer_client.status FROM customer_client WHERE customer_client.status = 'ENABLED' `, pageSize: 1000, }; console.log('Manager Account Request Debug:', { rawCustomerId: managerCustomerId, sanitizedCustomerId: sanitizedManagerId, baseUrl, endpoint: apiUrl, fullUrl: `${baseUrl}${apiUrl}`, headers: { ...requestHeaders, 'developer-token': developerToken ? '***HIDDEN***' : 'MISSING', }, requestBody, }); const response = await this.helpers.httpRequestWithAuthentication.call(this, 'googleAdsOAuth2', { method: 'POST', url: apiUrl, body: requestBody, headers: requestHeaders, }); console.log('getManagedAccounts API Response:', response); const results = []; if (response.results && Array.isArray(response.results)) { for (const result of response.results) { const client = result.customerClient; if (client && client.clientCustomer) { const customerId = client.clientCustomer.replace('customers/', ''); const name = client.descriptiveName || `Account ${customerId}`; const currency = client.currencyCode || ''; const timezone = client.timeZone || ''; results.push({ name: `${name} (${customerId})${currency ? ` - ${currency}` : ''}${timezone ? ` - ${timezone}` : ''}`, value: customerId, }); } } } console.log('Managed accounts found:', results.length); return { results: results.sort((a, b) => a.name.localeCompare(b.name)), }; } catch (error) { console.error('getManagedAccounts ERROR:', { error: error.message, httpCode: error.httpCode || error.status, responseBody: error.response?.body || error.body, requestConfig: error.config || error.request, stack: error.stack, fullError: error, }); // Rethrow com informações mais específicas const errorMessage = error.message || 'Unknown error occurred'; const httpCode = error.httpCode || error.status || 500; throw new Error(`Failed to load managed accounts: ${errorMessage} (HTTP ${httpCode})`); } }, async getConversionActions() { try { console.log('getConversionActions: Starting request...'); // Get credentials and developer token from authentication const credentials = await this.getCredentials('googleAdsOAuth2'); if (!credentials) { console.error('getConversionActions: No credentials found'); throw new Error('No credentials provided for Google Ads OAuth2'); } const customerId = credentials.customerId; const developerToken = credentials.developerToken; console.log('getConversionActions: Credentials check:', { hasCustomerId: !!customerId, hasDeveloperToken: !!developerToken, customerIdLength: customerId?.length || 0, }); if (!customerId) { console.error('getConversionActions: Customer ID is missing'); throw new Error('Customer ID is required'); } if (!developerToken) { console.error('getConversionActions: Developer token is missing'); throw new Error('Developer token is required'); } // Clean customer ID format (remove any dashes) const cleanCustomerId = customerId.replace(/-/g, ''); // Prepare GAQL query to get conversion actions const query = ` SELECT conversion_action.id, conversion_action.name, conversion_action.type, conversion_action.status, conversion_action.category, conversion_action.resource_name FROM conversion_action WHERE conversion_action.status = 'ENABLED' ORDER BY conversion_action.name `; const requestBody = { query: query.trim(), }; // Prepare headers const headers = { 'developer-token': developerToken, 'login-customer-id': cleanCustomerId, 'Content-Type': 'application/json', }; const url = `${GOOGLE_ADS_API_BASE_URL}/customers/${cleanCustomerId}/googleAds:search`; console.log('getConversionActions: Making API request to:', url); console.log('getConversionActions: Query:', query.trim()); const response = await this.helpers.httpRequestWithAuthentication.call(this, 'googleAdsOAuth2', { method: 'POST', url, headers, body: requestBody, }); console.log('getConversionActions: API response received'); if (!response || !response.results) { console.log('getConversionActions: No results in response'); return { results: [] }; } const results = response.results.map((result) => { const conversionAction = result.conversionAction; return { name: `${conversionAction.name} (${conversionAction.type})`, value: conversionAction.resourceName, description: `Status: ${conversionAction.status} | Category: ${conversionAction.category} | ID: ${conversionAction.id}`, }; }); console.log('getConversionActions: Processed results:', results.length); return { results }; } catch (error) { console.error('getConversionActions: Error occurred:', error); // Enhanced error handling const httpCode = error.httpCode || error.status || 0; let errorMessage = error.message || 'Unknown error'; if (error.response?.data?.error?.message) { errorMessage = error.response.data.error.message; } console.error('getConversionActions: Error details:', { httpCode, errorMessage, responseData: error.response?.data, }); throw new Error(`Failed to load conversion actions: ${errorMessage} (HTTP ${httpCode})`); } }, }, }; } /** * Helper function to extract conversion action value from resourceLocator or string */ getConversionActionValue(conversionActionParam) { // Handle resourceLocator format (new) if (conversionActionParam && typeof conversionActionParam === 'object' && conversionActionParam.value) { return conversionActionParam.value; } // Handle legacy string format (backwards compatibility) if (typeof conversionActionParam === 'string') { return conversionActionParam; } // Return empty string if invalid format return ''; } /** * Sleep utility for retry delays */ async sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Retry configuration interface */ getRetryConfig() { return { maxRetries: 3, baseDelayMs: 1000, // 1 second maxDelayMs: 30000, // 30 seconds retryableStatusCodes: [429, 500, 502, 503, 504], retryableErrors: ['ECONNRESET', 'ENOTFOUND', 'ECONNREFUSED', 'ETIMEDOUT'], }; } /** * Determine if an error should trigger a retry */ shouldRetry(error, retryAttempt, config) { // Check if we've exceeded max retries if (retryAttempt >= config.maxRetries) { return false; } // Check for specific retryable HTTP status codes const httpCode = error.httpCode || error.status || 0; if (config.retryableStatusCodes.includes(httpCode)) { return true; } // Check for specific network errors const errorCode = error.code || error.errno || ''; if (config.retryableErrors.includes(errorCode)) { return true; } // Check for specific error types that should be retried if (error instanceof GoogleAdsRateLimitError) { return true; } // Don't retry authentication or validation errors if (error instanceof GoogleAdsAuthenticationError || error instanceof GoogleAdsValidationError) { return false; } // Retry server errors (5xx) but not client errors (4xx) if (httpCode >= 500) { return true; } return false; } /** * Calculate delay for exponential backoff */ calculateDelay(retryAttempt, config, retryAfter) { // If rate limit error provides retry-after header, respect it if (retryAfter && retryAfter > 0) { return Math.min(retryAfter * 1000, config.maxDelayMs); } // Exponential backoff: baseDelay * (2 ^ retryAttempt) + jitter const exponentialDelay = config.baseDelayMs * Math.pow(2, retryAttempt); // Add jitter to prevent thundering herd (±25% random variation) const jitter = exponentialDelay * 0.25 * (Math.random() * 2 - 1); const delayWithJitter = exponentialDelay + jitter; // Cap at maximum delay return Math.min(Math.max(delayWithJitter, config.baseDelayMs), config.maxDelayMs); } /** * Execute function with retry logic */ async executeWithRetry(executeFunctions, operation, context, debugMode = false) { const config = this.getRetryConfig(); let lastError; for (let attempt = 0; attempt <= config.maxRetries; attempt++) { try { if (debugMode && attempt > 0) { executeFunctions.logger.debug(`${context} - Retry attempt ${attempt}/${config.maxRetries}`); } return await operation(); } catch (error) { lastError = error; const httpCode = error.httpCode || error.status || 0; // Log the error for debugging if (debugMode) { executeFunctions.logger.debug(`${context} - Error on attempt ${attempt + 1}:`, { httpCode, message: error.message, attempt: attempt + 1, maxRetries: config.maxRetries, }); } // For 403 errors, run diagnostics on first attempt if (httpCode === 403 && attempt === 0) { try { if (debugMode) { executeFunctions.logger.debug('Running permission diagnostics for 403 error...'); } const diagnostics = await this.diagnosePermissionIssues(executeFunctions); if (diagnostics.length > 0) { executeFunctions.logger.warn('Permission issue diagnostics:', { issues: diagnostics, context, }); } } catch (diagError) { if (debugMode) { executeFunctions.logger.debug('Diagnostics failed:', diagError); } } } // Check if we should retry if (attempt < config.maxRetries && this.shouldRetry(error, attempt, config)) { const delay = this.calculateDelay(attempt, config, error.retryAfter); if (debugMode) { executeFunctions.logger.debug(`${context} - Waiting ${delay}ms before retry ${attempt + 1}...`); } await this.sleep(delay); continue; } // No more retries or non-retryable error break; } } // All retries exhausted, throw the parsed error throw this.parseApiError(lastError, executeFunctions); } /** * Parse and categorize Google Ads API errors */ parseApiError(error, executeFunctions) { const debugMode = executeFunctions.getNodeParameter('debugMode', 0, false); const httpCode = error.httpCode || error.status || 0; if (debugMode) { executeFunctions.logger.debug('Error details for parsing:', { httpCode, message: error.message, responseBody: error.response?.data, stack: error.stack, }); } try { // Handle 403 Forbidden errors with detailed diagnostics if (httpCode === 403) { const errorMessage = this.buildDetailedAuthErrorMessage(error, executeFunctions); return new GoogleAdsApiError(executeFunctions.getNode(), errorMessage, httpCode, 'PERMISSION_DENIED'); } // Handle 401 Unauthorized errors if (httpCode === 401) { return new GoogleAdsAuthenticationError(executeFunctions.getNode(), 'Authentication failed. Please re-authenticate your Google Ads OAuth2 credentials. The access token may have expired or been revoked.'); } // Handle 400 Bad Request errors if (httpCode === 400) { let errorMessage = 'Bad request to Google Ads API'; const responseBody = error.response?.data; if (debugMode) { executeFunctions.logger.debug('400 Error Response Body:', { responseBody, errorResponseData: error.response?.data, errorMessage: error.message, requestUrl: error.config?.url, requestData: error.config?.data }); } if (responseBody && responseBody.error) { if (responseBody.error.message) { errorMessage = `${responseBody.error.message}`; } // Extract detailed error information const errorDetails = []; if (responseBody.error.details) { for (const detail of responseBody.error.details) { if (detail.errors) { for (const err of detail.errors) { if (err.message) { errorDetails.push(err.message); } if (err.errorCode && err.errorCode.fieldError) { errorDetails.push(`Field Error: ${err.errorCode.fieldError}`); } if (err.location && err.location.fieldPathElements) { const fieldPath = err.location.fieldPathElements .map((elem) => elem.fieldName) .join('.'); errorDetails.push(`Field: ${fieldPath}`); } } } } } if (errorDetails.length > 0) { errorMessage += ` | Details: ${errorDetails.join(' | ')}`; } } else { // Fallback if response body doesn't have expected structure errorMessage = `Bad request - please check your parameters. Response: ${JSON.stringify(responseBody)}`; } return new GoogleAdsValidationError(executeFunctions.getNode(), errorMessage); } // Handle rate limiting errors if (httpCode === 429) { const retryAfter = error.response?.headers?.['retry-after']; return new GoogleAdsRateLimitError(executeFunctions.getNode(), 'Rate limit exceeded. Please slow down your requests.', retryAfter ? parseInt(retryAfter) : undefined); } // Handle server errors if (httpCode >= 500) { return new GoogleAdsApiError(executeFunctions.getNode(), `Google Ads API server error (${httpCode}). Please try again later.`, httpCode, 'SERVER_ERROR'); } // Generic error handling const message = error.message || 'Unknown Google Ads API error'; return new GoogleAdsApiError(executeFunctions.getNode(), `Google Ads API Error: ${message}`, httpCode, 'UNKNOWN'); } catch (parseError) { if (debugMode) { executeFunctions.logger.error('Error parsing API error:', parseError); } return new GoogleAdsApiError(executeFunctions.getNode(), `Google Ads API Error: Unexpected error (${httpCode}): ${error.message || 'Unknown error'}`, httpCode, 'PARSE_ERROR'); } } /** * Build detailed error message for 403 authentication errors */ buildDetailedAuthErrorMessage(error, executeFunctions) { const baseMessage = 'Access denied to Google Ads API. This is typically a permissions or authentication issue.'; const suggestions = []; try { // Get current configuratio