@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,108 lines • 119 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.GoogleAdsConversion = void 0;
const n8n_workflow_1 = require("n8n-workflow");
const crypto_1 = require("crypto");
// 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: 'https://googleads.googleapis.com/v17',
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 = 'https://googleads.googleapis.com/v17';
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 = `https://googleads.googleapis.com/v17/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 configuration for diagnostics
const accountType = executeFunctions.getNodeParameter('account