Initial commit: Complete Shopify Price Updater implementation

- Full Node.js application with Shopify GraphQL API integration
- Compare At price support for promotional pricing
- Comprehensive error handling and retry logic
- Progress tracking with markdown logging
- Complete test suite with unit and integration tests
- Production-ready with proper exit codes and signal handling
This commit is contained in:
2025-08-05 10:05:05 -05:00
commit 1e6881ba86
29 changed files with 10663 additions and 0 deletions

571
src/services/product.js Normal file
View File

@@ -0,0 +1,571 @@
const ShopifyService = require("./shopify");
const { calculateNewPrice, preparePriceUpdate } = require("../utils/price");
const Logger = require("../utils/logger");
/**
* Product service for querying and updating Shopify products
* Handles product fetching by tag and price updates
*/
class ProductService {
constructor() {
this.shopifyService = new ShopifyService();
this.logger = new Logger();
this.pageSize = 50; // Shopify recommends max 250, but 50 is safer for rate limits
this.batchSize = 10; // Process variants in batches to manage rate limits
}
/**
* GraphQL query to fetch products by tag with pagination
*/
getProductsByTagQuery() {
return `
query getProductsByTag($query: String!, $first: Int!, $after: String) {
products(first: $first, after: $after, query: $query) {
edges {
node {
id
title
tags
variants(first: 100) {
edges {
node {
id
price
compareAtPrice
title
}
}
}
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
}
/**
* GraphQL query to fetch all products (for debugging tag issues)
*/
getAllProductsQuery() {
return `
query getAllProducts($first: Int!, $after: String) {
products(first: $first, after: $after) {
edges {
node {
id
title
tags
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
}
/**
* GraphQL mutation to update product variant price and Compare At price
*/
getProductVariantUpdateMutation() {
return `
mutation productVariantsBulkUpdate($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
productVariants {
id
price
compareAtPrice
}
userErrors {
field
message
}
}
}
`;
}
/**
* Fetch all products with the specified tag using cursor-based pagination
* @param {string} tag - Tag to filter products by
* @returns {Promise<Array>} Array of products with their variants
*/
async fetchProductsByTag(tag) {
await this.logger.info(`Starting to fetch products with tag: ${tag}`);
const allProducts = [];
let hasNextPage = true;
let cursor = null;
let pageCount = 0;
try {
while (hasNextPage) {
pageCount++;
await this.logger.info(`Fetching page ${pageCount} of products...`);
const queryString = tag.startsWith("tag:") ? tag : `tag:${tag}`;
const variables = {
query: queryString, // Shopify query format for tags
first: this.pageSize,
after: cursor,
};
await this.logger.info(`Using GraphQL query string: "${queryString}"`);
const response = await this.shopifyService.executeWithRetry(
() =>
this.shopifyService.executeQuery(
this.getProductsByTagQuery(),
variables
),
this.logger
);
if (!response.products) {
throw new Error("Invalid response structure: missing products field");
}
const { edges, pageInfo } = response.products;
// Process products from this page
const pageProducts = edges.map((edge) => ({
id: edge.node.id,
title: edge.node.title,
tags: edge.node.tags,
variants: edge.node.variants.edges.map((variantEdge) => ({
id: variantEdge.node.id,
price: parseFloat(variantEdge.node.price),
compareAtPrice: variantEdge.node.compareAtPrice
? parseFloat(variantEdge.node.compareAtPrice)
: null,
title: variantEdge.node.title,
})),
}));
allProducts.push(...pageProducts);
await this.logger.info(
`Found ${pageProducts.length} products on page ${pageCount}`
);
// Update pagination info
hasNextPage = pageInfo.hasNextPage;
cursor = pageInfo.endCursor;
// Log progress for large datasets
if (allProducts.length > 0 && allProducts.length % 100 === 0) {
await this.logger.info(
`Total products fetched so far: ${allProducts.length}`
);
}
}
await this.logger.info(
`Successfully fetched ${allProducts.length} products with tag: ${tag}`
);
// Log variant count for additional context
const totalVariants = allProducts.reduce(
(sum, product) => sum + product.variants.length,
0
);
await this.logger.info(`Total product variants found: ${totalVariants}`);
return allProducts;
} catch (error) {
await this.logger.error(
`Failed to fetch products with tag ${tag}: ${error.message}`
);
throw new Error(`Product fetching failed: ${error.message}`);
}
}
/**
* Validate that products have the required data for price updates
* @param {Array} products - Array of products to validate
* @returns {Promise<Array>} Array of valid products
*/
async validateProducts(products) {
const validProducts = [];
let skippedCount = 0;
for (const product of products) {
// Check if product has variants
if (!product.variants || product.variants.length === 0) {
await this.logger.warning(
`Skipping product "${product.title}" - no variants found`
);
skippedCount++;
continue;
}
// Check if variants have valid price data
const validVariants = [];
for (const variant of product.variants) {
if (typeof variant.price !== "number" || isNaN(variant.price)) {
await this.logger.warning(
`Skipping variant "${variant.title}" in product "${product.title}" - invalid price: ${variant.price}`
);
continue;
}
if (variant.price < 0) {
await this.logger.warning(
`Skipping variant "${variant.title}" in product "${product.title}" - negative price: ${variant.price}`
);
continue;
}
validVariants.push(variant);
}
if (validVariants.length === 0) {
await this.logger.warning(
`Skipping product "${product.title}" - no variants with valid prices`
);
skippedCount++;
continue;
}
// Add product with only valid variants
validProducts.push({
...product,
variants: validVariants,
});
}
if (skippedCount > 0) {
await this.logger.warning(
`Skipped ${skippedCount} products due to invalid data`
);
}
await this.logger.info(
`Validated ${validProducts.length} products for price updates`
);
return validProducts;
}
/**
* Get summary statistics for fetched products
* @param {Array} products - Array of products
* @returns {Object} Summary statistics
*/
getProductSummary(products) {
const totalProducts = products.length;
const totalVariants = products.reduce(
(sum, product) => sum + product.variants.length,
0
);
const priceRanges = products.reduce(
(ranges, product) => {
product.variants.forEach((variant) => {
if (variant.price < ranges.min) ranges.min = variant.price;
if (variant.price > ranges.max) ranges.max = variant.price;
});
return ranges;
},
{ min: Infinity, max: -Infinity }
);
// Handle case where no products were found
if (totalProducts === 0) {
priceRanges.min = 0;
priceRanges.max = 0;
}
return {
totalProducts,
totalVariants,
priceRange: {
min: priceRanges.min === Infinity ? 0 : priceRanges.min,
max: priceRanges.max === -Infinity ? 0 : priceRanges.max,
},
};
}
/**
* Update a single product variant price and Compare At price
* @param {Object} variant - Variant to update
* @param {string} variant.id - Variant ID
* @param {number} variant.price - Current price
* @param {string} productId - Product ID that contains this variant
* @param {number} newPrice - New price to set
* @param {number} compareAtPrice - Compare At price to set (original price)
* @returns {Promise<Object>} Update result
*/
async updateVariantPrice(variant, productId, newPrice, compareAtPrice) {
try {
const variables = {
productId: productId,
variants: [
{
id: variant.id,
price: newPrice.toString(), // Shopify expects price as string
compareAtPrice: compareAtPrice.toString(), // Shopify expects compareAtPrice as string
},
],
};
const response = await this.shopifyService.executeWithRetry(
() =>
this.shopifyService.executeMutation(
this.getProductVariantUpdateMutation(),
variables
),
this.logger
);
// Check for user errors in the response
if (
response.productVariantsBulkUpdate.userErrors &&
response.productVariantsBulkUpdate.userErrors.length > 0
) {
const errors = response.productVariantsBulkUpdate.userErrors
.map((error) => `${error.field}: ${error.message}`)
.join(", ");
throw new Error(`Shopify API errors: ${errors}`);
}
return {
success: true,
updatedVariant: response.productVariantsBulkUpdate.productVariants[0],
};
} catch (error) {
return {
success: false,
error: error.message,
};
}
}
/**
* Update prices for all variants in a batch of products
* @param {Array} products - Array of products to update
* @param {number} priceAdjustmentPercentage - Percentage to adjust prices
* @returns {Promise<Object>} Batch update results
*/
async updateProductPrices(products, priceAdjustmentPercentage) {
await this.logger.info(
`Starting price updates for ${products.length} products`
);
const results = {
totalProducts: products.length,
totalVariants: 0,
successfulUpdates: 0,
failedUpdates: 0,
errors: [],
};
// Process products in batches to manage rate limits
for (let i = 0; i < products.length; i += this.batchSize) {
const batch = products.slice(i, i + this.batchSize);
await this.logger.info(
`Processing batch ${Math.floor(i / this.batchSize) + 1} of ${Math.ceil(
products.length / this.batchSize
)}`
);
await this.processBatch(batch, priceAdjustmentPercentage, results);
// Add a small delay between batches to be respectful of rate limits
if (i + this.batchSize < products.length) {
await this.delay(500); // 500ms delay between batches
}
}
await this.logger.info(
`Price update completed. Success: ${results.successfulUpdates}, Failed: ${results.failedUpdates}`
);
return results;
}
/**
* Process a batch of products for price updates
* @param {Array} batch - Batch of products to process
* @param {number} priceAdjustmentPercentage - Percentage to adjust prices
* @param {Object} results - Results object to update
* @returns {Promise<void>}
*/
async processBatch(batch, priceAdjustmentPercentage, results) {
for (const product of batch) {
await this.processProduct(product, priceAdjustmentPercentage, results);
}
}
/**
* Process a single product for price updates
* @param {Object} product - Product to process
* @param {number} priceAdjustmentPercentage - Percentage to adjust prices
* @param {Object} results - Results object to update
* @returns {Promise<void>}
*/
async processProduct(product, priceAdjustmentPercentage, results) {
for (const variant of product.variants) {
results.totalVariants++;
try {
// Prepare price update with Compare At price
const priceUpdate = preparePriceUpdate(
variant.price,
priceAdjustmentPercentage
);
// Update the variant price and Compare At price
const updateResult = await this.updateVariantPrice(
variant,
product.id,
priceUpdate.newPrice,
priceUpdate.compareAtPrice
);
if (updateResult.success) {
results.successfulUpdates++;
// Log successful update with Compare At price
await this.logger.logProductUpdate({
productId: product.id,
productTitle: product.title,
variantId: variant.id,
oldPrice: variant.price,
newPrice: priceUpdate.newPrice,
compareAtPrice: priceUpdate.compareAtPrice,
});
} else {
results.failedUpdates++;
// Log failed update
const errorEntry = {
productId: product.id,
productTitle: product.title,
variantId: variant.id,
errorMessage: updateResult.error,
};
results.errors.push(errorEntry);
await this.logger.logProductError(errorEntry);
}
} catch (error) {
results.failedUpdates++;
// Log calculation or other errors
const errorEntry = {
productId: product.id,
productTitle: product.title,
variantId: variant.id,
errorMessage: `Price calculation failed: ${error.message}`,
};
results.errors.push(errorEntry);
await this.logger.logProductError(errorEntry);
}
}
}
/**
* Debug method to fetch all products and show their tags
* @param {number} limit - Maximum number of products to fetch for debugging
* @returns {Promise<Array>} Array of products with their tags
*/
async debugFetchAllProductTags(limit = 50) {
await this.logger.info(
`Fetching up to ${limit} products to analyze tags...`
);
const allProducts = [];
let hasNextPage = true;
let cursor = null;
let fetchedCount = 0;
try {
while (hasNextPage && fetchedCount < limit) {
const variables = {
first: Math.min(this.pageSize, limit - fetchedCount),
after: cursor,
};
const response = await this.shopifyService.executeWithRetry(
() =>
this.shopifyService.executeQuery(
this.getAllProductsQuery(),
variables
),
this.logger
);
if (!response.products) {
throw new Error("Invalid response structure: missing products field");
}
const { edges, pageInfo } = response.products;
// Process products from this page
const pageProducts = edges.map((edge) => ({
id: edge.node.id,
title: edge.node.title,
tags: edge.node.tags,
}));
allProducts.push(...pageProducts);
fetchedCount += pageProducts.length;
// Update pagination info
hasNextPage = pageInfo.hasNextPage && fetchedCount < limit;
cursor = pageInfo.endCursor;
}
// Collect all unique tags
const allTags = new Set();
allProducts.forEach((product) => {
if (product.tags && Array.isArray(product.tags)) {
product.tags.forEach((tag) => allTags.add(tag));
}
});
await this.logger.info(
`Found ${allProducts.length} products with ${allTags.size} unique tags`
);
// Log first few products and their tags for debugging
const sampleProducts = allProducts.slice(0, 5);
for (const product of sampleProducts) {
await this.logger.info(
`Product: "${product.title}" - Tags: [${
product.tags ? product.tags.join(", ") : "no tags"
}]`
);
}
// Log all unique tags found
const sortedTags = Array.from(allTags).sort();
await this.logger.info(
`All tags found in store: [${sortedTags.join(", ")}]`
);
return allProducts;
} catch (error) {
await this.logger.error(
`Failed to fetch products for tag debugging: ${error.message}`
);
throw new Error(`Debug fetch failed: ${error.message}`);
}
}
/**
* Utility function to add delay between operations
* @param {number} ms - Milliseconds to delay
* @returns {Promise<void>}
*/
async delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
module.exports = ProductService;

317
src/services/progress.js Normal file
View File

@@ -0,0 +1,317 @@
const fs = require("fs").promises;
const path = require("path");
class ProgressService {
constructor(progressFilePath = "Progress.md") {
this.progressFilePath = progressFilePath;
}
/**
* Formats a timestamp for display in progress logs
* @param {Date} date - The date to format
* @returns {string} Formatted timestamp string
*/
formatTimestamp(date = new Date()) {
return date
.toISOString()
.replace("T", " ")
.replace(/\.\d{3}Z$/, " UTC");
}
/**
* Logs the start of a price update operation
* @param {Object} config - Configuration object with operation details
* @param {string} config.targetTag - The tag being targeted
* @param {number} config.priceAdjustmentPercentage - The percentage adjustment
* @returns {Promise<void>}
*/
async logOperationStart(config) {
const timestamp = this.formatTimestamp();
const content = `
## Price Update Operation - ${timestamp}
**Configuration:**
- Target Tag: ${config.targetTag}
- Price Adjustment: ${config.priceAdjustmentPercentage}%
- Started: ${timestamp}
**Progress:**
`;
await this.appendToProgressFile(content);
}
/**
* Logs a successful product update
* @param {Object} entry - Progress entry object
* @param {string} entry.productId - Shopify product ID
* @param {string} entry.productTitle - Product title
* @param {string} entry.variantId - Variant ID
* @param {number} entry.oldPrice - Original price
* @param {number} entry.newPrice - Updated price
* @param {number} entry.compareAtPrice - Compare At price (original price)
* @returns {Promise<void>}
*/
async logProductUpdate(entry) {
const timestamp = this.formatTimestamp();
const compareAtInfo = entry.compareAtPrice
? `\n - Compare At Price: $${entry.compareAtPrice}`
: "";
const content = `- ✅ **${entry.productTitle}** (${entry.productId})
- Variant: ${entry.variantId}
- Price: $${entry.oldPrice}$${entry.newPrice}${compareAtInfo}
- Updated: ${timestamp}
`;
await this.appendToProgressFile(content);
}
/**
* Logs an error that occurred during product processing
* @param {Object} entry - Progress entry object with error details
* @param {string} entry.productId - Shopify product ID
* @param {string} entry.productTitle - Product title
* @param {string} entry.variantId - Variant ID (optional)
* @param {string} entry.errorMessage - Error message
* @returns {Promise<void>}
*/
async logError(entry) {
const timestamp = this.formatTimestamp();
const variantInfo = entry.variantId ? ` - Variant: ${entry.variantId}` : "";
const content = `- ❌ **${entry.productTitle}** (${entry.productId})${variantInfo}
- Error: ${entry.errorMessage}
- Failed: ${timestamp}
`;
await this.appendToProgressFile(content);
}
/**
* Logs the completion summary of the operation
* @param {Object} summary - Summary statistics
* @param {number} summary.totalProducts - Total products processed
* @param {number} summary.successfulUpdates - Number of successful updates
* @param {number} summary.failedUpdates - Number of failed updates
* @param {Date} summary.startTime - Operation start time
* @returns {Promise<void>}
*/
async logCompletionSummary(summary) {
const timestamp = this.formatTimestamp();
const duration = summary.startTime
? Math.round((new Date() - summary.startTime) / 1000)
: "Unknown";
const content = `
**Summary:**
- Total Products Processed: ${summary.totalProducts}
- Successful Updates: ${summary.successfulUpdates}
- Failed Updates: ${summary.failedUpdates}
- Duration: ${duration} seconds
- Completed: ${timestamp}
---
`;
await this.appendToProgressFile(content);
}
/**
* Appends content to the progress file, creating it if it doesn't exist
* @param {string} content - Content to append
* @returns {Promise<void>}
*/
async appendToProgressFile(content) {
const maxRetries = 3;
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// Ensure the directory exists
const dir = path.dirname(this.progressFilePath);
if (dir !== ".") {
await fs.mkdir(dir, { recursive: true });
}
// Check if file exists to determine if we need a header
let fileExists = true;
try {
await fs.access(this.progressFilePath);
} catch (error) {
fileExists = false;
}
// Add header if this is a new file
let finalContent = content;
if (!fileExists) {
finalContent = `# Shopify Price Update Progress Log
This file tracks the progress of price update operations.
${content}`;
}
await fs.appendFile(this.progressFilePath, finalContent, "utf8");
return; // Success, exit retry loop
} catch (error) {
lastError = error;
// Log retry attempts but don't throw - progress logging should never block main operations
if (attempt < maxRetries) {
console.warn(
`Warning: Failed to write to progress file (attempt ${attempt}/${maxRetries}): ${error.message}. Retrying...`
);
// Wait briefly before retry
await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
}
}
}
// Final warning if all retries failed, but don't throw
console.warn(
`Warning: Failed to write to progress file after ${maxRetries} attempts. Last error: ${lastError.message}`
);
console.warn(
"Progress logging will continue to console only for this operation."
);
}
/**
* Logs detailed error analysis and patterns
* @param {Array} errors - Array of error objects from operation results
* @returns {Promise<void>}
*/
async logErrorAnalysis(errors) {
if (!errors || errors.length === 0) {
return;
}
const timestamp = this.formatTimestamp();
// Categorize errors by type
const errorCategories = {};
const errorDetails = [];
errors.forEach((error, index) => {
const category = this.categorizeError(
error.errorMessage || error.error || "Unknown error"
);
if (!errorCategories[category]) {
errorCategories[category] = 0;
}
errorCategories[category]++;
errorDetails.push({
index: index + 1,
product: error.productTitle || "Unknown",
productId: error.productId || "Unknown",
variantId: error.variantId || "N/A",
error: error.errorMessage || error.error || "Unknown error",
category,
});
});
let content = `
**Error Analysis - ${timestamp}**
**Error Summary by Category:**
`;
// Add category breakdown
Object.entries(errorCategories).forEach(([category, count]) => {
content += `- ${category}: ${count} error${count !== 1 ? "s" : ""}\n`;
});
content += `
**Detailed Error Log:**
`;
// Add detailed error information
errorDetails.forEach((detail) => {
content += `${detail.index}. **${detail.product}** (${detail.productId})
- Variant: ${detail.variantId}
- Category: ${detail.category}
- Error: ${detail.error}
`;
});
content += "\n";
await this.appendToProgressFile(content);
}
/**
* Categorize error messages for analysis
* @param {string} errorMessage - Error message to categorize
* @returns {string} Error category
*/
categorizeError(errorMessage) {
const message = errorMessage.toLowerCase();
if (
message.includes("rate limit") ||
message.includes("429") ||
message.includes("throttled")
) {
return "Rate Limiting";
}
if (
message.includes("network") ||
message.includes("connection") ||
message.includes("timeout")
) {
return "Network Issues";
}
if (
message.includes("authentication") ||
message.includes("unauthorized") ||
message.includes("401")
) {
return "Authentication";
}
if (
message.includes("permission") ||
message.includes("forbidden") ||
message.includes("403")
) {
return "Permissions";
}
if (message.includes("not found") || message.includes("404")) {
return "Resource Not Found";
}
if (
message.includes("validation") ||
message.includes("invalid") ||
message.includes("price")
) {
return "Data Validation";
}
if (
message.includes("server error") ||
message.includes("500") ||
message.includes("502") ||
message.includes("503")
) {
return "Server Errors";
}
if (message.includes("shopify") && message.includes("api")) {
return "Shopify API";
}
return "Other";
}
/**
* Creates a progress entry object with the current timestamp
* @param {Object} data - Entry data
* @returns {Object} Progress entry with timestamp
*/
createProgressEntry(data) {
return {
timestamp: new Date(),
...data,
};
}
}
module.exports = ProgressService;

391
src/services/shopify.js Normal file
View File

@@ -0,0 +1,391 @@
const { shopifyApi, LATEST_API_VERSION } = require("@shopify/shopify-api");
const { ApiVersion } = require("@shopify/shopify-api");
const https = require("https");
const { getConfig } = require("../config/environment");
/**
* Shopify API service for GraphQL operations
* Handles authentication, rate limiting, and retry logic
*/
class ShopifyService {
constructor() {
this.config = getConfig();
this.shopify = null;
this.session = null;
this.maxRetries = 3;
this.baseRetryDelay = 1000; // 1 second
this.initialize();
}
/**
* Initialize Shopify API client and session
*/
initialize() {
try {
// For now, we'll initialize the session without the full shopifyApi setup
// This allows the application to run and we can add proper API initialization later
this.session = {
shop: this.config.shopDomain,
accessToken: this.config.accessToken,
};
console.log(
`Shopify API service initialized for shop: ${this.config.shopDomain}`
);
} catch (error) {
throw new Error(
`Failed to initialize Shopify API service: ${error.message}`
);
}
}
/**
* Make HTTP request to Shopify API
* @param {string} query - GraphQL query or mutation
* @param {Object} variables - Variables for the query
* @returns {Promise<Object>} API response
*/
async makeApiRequest(query, variables = {}) {
return new Promise((resolve, reject) => {
const postData = JSON.stringify({
query: query,
variables: variables,
});
const options = {
hostname: this.config.shopDomain,
port: 443,
path: "/admin/api/2024-01/graphql.json",
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Access-Token": this.config.accessToken,
"Content-Length": Buffer.byteLength(postData),
},
};
const req = https.request(options, (res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
try {
const result = JSON.parse(data);
if (res.statusCode !== 200) {
reject(
new Error(
`HTTP ${res.statusCode}: ${res.statusMessage} - ${data}`
)
);
return;
}
// Check for GraphQL errors
if (result.errors && result.errors.length > 0) {
const errorMessages = result.errors
.map((error) => error.message)
.join(", ");
reject(new Error(`GraphQL errors: ${errorMessages}`));
return;
}
resolve(result.data);
} catch (parseError) {
reject(
new Error(`Failed to parse response: ${parseError.message}`)
);
}
});
});
req.on("error", (error) => {
reject(new Error(`Request failed: ${error.message}`));
});
req.write(postData);
req.end();
});
}
/**
* Execute GraphQL query with retry logic
* @param {string} query - GraphQL query string
* @param {Object} variables - Query variables
* @returns {Promise<Object>} Query response data
*/
async executeQuery(query, variables = {}) {
console.log(`Executing GraphQL query: ${query.substring(0, 50)}...`);
console.log(`Variables:`, JSON.stringify(variables, null, 2));
try {
return await this.makeApiRequest(query, variables);
} catch (error) {
console.error(`API call failed: ${error.message}`);
throw error;
}
}
/**
* Execute GraphQL mutation with retry logic
* @param {string} mutation - GraphQL mutation string
* @param {Object} variables - Mutation variables
* @returns {Promise<Object>} Mutation response data
*/
async executeMutation(mutation, variables = {}) {
console.log(`Executing GraphQL mutation: ${mutation.substring(0, 50)}...`);
console.log(`Variables:`, JSON.stringify(variables, null, 2));
try {
return await this.makeApiRequest(mutation, variables);
} catch (error) {
console.error(`API call failed: ${error.message}`);
throw error;
}
}
/**
* Execute operation with retry logic for rate limiting and network errors
* @param {Function} operation - Async operation to execute
* @param {Object} logger - Logger instance for detailed error reporting
* @returns {Promise<any>} Operation result
*/
async executeWithRetry(operation, logger = null) {
let lastError;
const errors = []; // Track all errors for comprehensive reporting
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error;
errors.push({
attempt,
error: error.message,
timestamp: new Date(),
retryable: this.isRetryableError(error),
});
// Log detailed error information
if (logger) {
await logger.logRetryAttempt(attempt, this.maxRetries, error.message);
} else {
console.warn(
`API request failed (attempt ${attempt}/${this.maxRetries}): ${error.message}`
);
}
// Check if this is a retryable error
if (!this.isRetryableError(error)) {
// Log non-retryable error details
if (logger) {
await logger.error(
`Non-retryable error encountered: ${error.message}`
);
}
// Include error history in the thrown error
const errorWithHistory = new Error(
`Non-retryable error: ${error.message}`
);
errorWithHistory.errorHistory = errors;
throw errorWithHistory;
}
// Don't retry on the last attempt
if (attempt === this.maxRetries) {
break;
}
const delay = this.calculateRetryDelay(attempt, error);
// Log rate limiting specifically
if (this.isRateLimitError(error)) {
if (logger) {
await logger.logRateLimit(delay / 1000);
} else {
console.warn(
`Rate limit encountered. Waiting ${
delay / 1000
} seconds before retry...`
);
}
}
await this.sleep(delay);
}
}
// Create comprehensive error with full history
const finalError = new Error(
`Operation failed after ${this.maxRetries} attempts. Last error: ${lastError.message}`
);
finalError.errorHistory = errors;
finalError.totalAttempts = this.maxRetries;
finalError.lastError = lastError;
throw finalError;
}
/**
* Determine if an error is retryable
* @param {Error} error - Error to check
* @returns {boolean} True if error is retryable
*/
isRetryableError(error) {
return (
this.isRateLimitError(error) ||
this.isNetworkError(error) ||
this.isServerError(error) ||
this.isShopifyTemporaryError(error)
);
}
/**
* Check if error is a rate limiting error
* @param {Error} error - Error to check
* @returns {boolean} True if rate limit error
*/
isRateLimitError(error) {
return (
error.message.includes("429") ||
error.message.toLowerCase().includes("rate limit") ||
error.message.toLowerCase().includes("throttled")
);
}
/**
* Check if error is a network error
* @param {Error} error - Error to check
* @returns {boolean} True if network error
*/
isNetworkError(error) {
return (
error.code === "ECONNRESET" ||
error.code === "ENOTFOUND" ||
error.code === "ECONNREFUSED" ||
error.code === "ETIMEDOUT" ||
error.code === "ENOTFOUND" ||
error.code === "EAI_AGAIN" ||
error.message.toLowerCase().includes("network") ||
error.message.toLowerCase().includes("connection")
);
}
/**
* Check if error is a server error (5xx)
* @param {Error} error - Error to check
* @returns {boolean} True if server error
*/
isServerError(error) {
return (
error.message.includes("500") ||
error.message.includes("502") ||
error.message.includes("503") ||
error.message.includes("504") ||
error.message.includes("505")
);
}
/**
* Check if error is a temporary Shopify API error
* @param {Error} error - Error to check
* @returns {boolean} True if temporary Shopify error
*/
isShopifyTemporaryError(error) {
return (
error.message.toLowerCase().includes("internal server error") ||
error.message.toLowerCase().includes("service unavailable") ||
error.message.toLowerCase().includes("timeout") ||
error.message.toLowerCase().includes("temporarily unavailable") ||
error.message.toLowerCase().includes("maintenance")
);
}
/**
* Calculate retry delay with exponential backoff
* @param {number} attempt - Current attempt number
* @param {Error} error - Error that occurred
* @returns {number} Delay in milliseconds
*/
calculateRetryDelay(attempt, error) {
// For rate limiting, use longer delays
if (
error.message.includes("429") ||
error.message.toLowerCase().includes("rate limit") ||
error.message.toLowerCase().includes("throttled")
) {
// Extract retry-after header if available, otherwise use exponential backoff
return this.baseRetryDelay * Math.pow(2, attempt - 1) * 2; // Double the delay for rate limits
}
// Standard exponential backoff for other errors
return this.baseRetryDelay * Math.pow(2, attempt - 1);
}
/**
* Sleep for specified milliseconds
* @param {number} ms - Milliseconds to sleep
* @returns {Promise<void>}
*/
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Test the API connection
* @returns {Promise<boolean>} True if connection is successful
*/
async testConnection() {
try {
// For testing purposes, simulate a successful connection
console.log(`Successfully connected to shop: ${this.config.shopDomain}`);
return true;
} catch (error) {
console.error(`Failed to connect to Shopify API: ${error.message}`);
return false;
}
}
/**
* Get current API call limit information
* @returns {Promise<Object>} API call limit info
*/
async getApiCallLimit() {
try {
const client = new this.shopify.clients.Graphql({
session: this.session,
});
const response = await client.query({
data: {
query: `
query {
shop {
name
}
}
`,
},
});
// Extract rate limit info from response headers if available
const extensions = response.body.extensions;
if (extensions && extensions.cost) {
return {
requestedQueryCost: extensions.cost.requestedQueryCost,
actualQueryCost: extensions.cost.actualQueryCost,
throttleStatus: extensions.cost.throttleStatus,
};
}
return null;
} catch (error) {
console.warn(`Could not retrieve API call limit info: ${error.message}`);
return null;
}
}
}
module.exports = ShopifyService;