UNPKG

@gravypower/node-red-franklinwh

Version:

Node-RED node to control FranklinWH gateway

238 lines (207 loc) 9.44 kB
const franklinwh = require('franklinwh'); /** * API Handler for FranklinWH integration * Handles API calls with retry logic, timeouts, and error categorization */ module.exports = { // Configuration constants CONFIG: { MAX_RETRIES: 3, INITIAL_BACKOFF_MS: 1000, MAX_BACKOFF_MS: 10000, TIMEOUT_MS: 15000, INITIAL_TIMEOUT_MS: 30000, // Longer timeout for first connection }, // Track first call status isFirstCall: true, /** * Get or create an API instance via the config node. * This now delegates to the configuration node's getApi method, * ensuring only one connection per gateway and proper credential handling. * @param {Object} server - The configuration node * @param {boolean} needsNewAuth - If a new authentication/connection is required * @returns {Promise<Object>} API instance */ getApiInstance: async function(server, needsNewAuth = false) { if (!server || typeof server.getApi !== "function") { // Extra debugging to help diagnose object received let serverType = typeof server; let serverKeys = server && typeof server === "object" ? Object.keys(server).join(", ") : "N/A"; console.error( "[FranklinWH] getApiInstance error: Expected config node with getApi(). " + `Type: ${serverType}, Keys: ${serverKeys}, Value: ${JSON.stringify(server)}` ); throw new Error("Invalid config node. Cannot obtain API instance."); } return await server.getApi(needsNewAuth); }, /** * Categorize error types to handle them appropriately * @param {Error} err - The error to categorize * @returns {string} The error category */ categorizeError: function(err) { const message = err.message || ''; if (message.includes("Unauthenticated") || message.includes("401") || message.includes("token") || message.includes("auth")) { return "AUTH"; } if (message.includes("timeout") || message.includes("ETIMEDOUT") || message.includes("ESOCKETTIMEDOUT")) { return "TIMEOUT"; } if (message.includes("ECONNREFUSED") || message.includes("ENOTFOUND") || message.includes("ENETUNREACH") || message.includes("ECONNRESET") || message.includes("EHOSTUNREACH") || message.includes("503") || message.includes("502")) { return "NETWORK"; } return "UNKNOWN"; }, /** * Calculate backoff time with exponential increase and jitter * @param {number} attempt - Current retry attempt (starting at 1) * @returns {number} Milliseconds to wait before next retry */ calculateBackoff: function(attempt) { const backoff = Math.min( this.CONFIG.MAX_BACKOFF_MS, this.CONFIG.INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1) ); // Add a small random jitter (±10%) to prevent synchronized retries const jitter = backoff * 0.1 * (Math.random() * 2 - 1); return Math.floor(backoff + jitter); }, /** * Execute a function with timeout * @param {Function} fn - Function to execute (should return a promise) * @param {number} timeoutMs - Timeout in milliseconds * @returns {Promise} Promise that resolves with the function result or rejects with timeout */ executeWithTimeout: async function(fn, timeoutMs) { let timeoutId; // Use longer timeout for first call const effectiveTimeout = this.isFirstCall ? this.CONFIG.INITIAL_TIMEOUT_MS : (timeoutMs || this.CONFIG.TIMEOUT_MS); const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error(`API call timed out after ${effectiveTimeout}ms${this.isFirstCall ? ' (first call)' : ''}`)); }, effectiveTimeout); }); try { const result = await Promise.race([fn(), timeoutPromise]); clearTimeout(timeoutId); this.isFirstCall = false; // Clear first call flag after successful call return result; } catch (err) { clearTimeout(timeoutId); throw err; } }, /** * Execute an API call with comprehensive retry logic * @param {Object} node - The Node-RED node instance * @param {Object} server - The server configuration node * @param {Function} apiCall - The API call to execute * @param {Object} msg - The message object * @param {Function} send - The send function * @param {Function} done - The done function */ executeWithRetry: async function(node, server, apiCall, msg, send, done) { const setNodeStatus = (status) => { if (node && node.status) { node.status(status); } }; const log = (level, message) => { if (node) { if (level === 'debug' && node.debug) { node.debug(`[FranklinWH] ${message}`); } else if (level === 'error' && node.error) { node.error(`[FranklinWH] ${message}`, msg); } else if (level === 'warn' && node.warn) { node.warn(`[FranklinWH] ${message}`); } } }; // Track if this is first connection attempt for this server const isFirstConnection = this.isFirstCall; log('debug', `Starting API call for node ${node.id}`); setNodeStatus({fill: "blue", shape: "dot", text: "requesting..."}); let attempt = 0; let lastError = null; let needsNewAuth = false; while (attempt < this.CONFIG.MAX_RETRIES) { attempt++; try { // Get API instance, force refresh if auth failed previously log('debug', `Getting API instance (attempt ${attempt}${needsNewAuth ? ', with auth refresh' : ''})`); const api = await this.getApiInstance(server, needsNewAuth); // Execute API call with timeout log('debug', `Making API call (attempt ${attempt})`); const result = await this.executeWithTimeout( () => apiCall(api), this.CONFIG.TIMEOUT_MS ); // Success - update message and status log('debug', `API call successful, result: ${JSON.stringify(result)}`); msg.payload = result; msg.attempt = attempt; setNodeStatus({fill: "green", shape: "dot", text: "success"}); send(msg); if (done) done(); return; } catch (err) { lastError = err; log('debug', `Error in API call (attempt ${attempt}): ${err.message}`); // Categorize error to determine retry strategy const errorType = this.categorizeError(err); log('debug', `Error categorized as: ${errorType}`); if (errorType === "AUTH") { // Force authentication refresh on next attempt needsNewAuth = true; setNodeStatus({fill: "yellow", shape: "ring", text: "auth retry..."}); } else if (errorType === "NETWORK" || errorType === "TIMEOUT") { // Network or timeout errors should use backoff const backoffMs = this.calculateBackoff(attempt); const retryMessage = isFirstConnection ? `initial connection retry in ${Math.round(backoffMs/1000)}s...` : `retry in ${Math.round(backoffMs/1000)}s...`; setNodeStatus({fill: "yellow", shape: "ring", text: retryMessage}); log('debug', `Waiting ${backoffMs}ms before retry ${attempt}`); await new Promise(resolve => setTimeout(resolve, backoffMs)); } else { // Unknown errors or business logic errors - don't retry log('debug', `Not retrying error type: ${errorType}`); break; } // If we've hit max retries, exit the loop if (attempt >= this.CONFIG.MAX_RETRIES) { log('debug', `Max retry attempts (${this.CONFIG.MAX_RETRIES}) reached`); const finalError = isFirstConnection ? new Error(`Failed to establish initial connection after ${attempt} attempts: ${lastError.message}`) : new Error(`Max retries (${attempt}) reached: ${lastError.message}`); lastError = finalError; break; } } } // If we get here, all retries failed const errorMessage = `Error after ${attempt} attempts: ${lastError.message}`; log('error', errorMessage); if (lastError.stack) { log('debug', `Stack trace: ${lastError.stack}`); } setNodeStatus({fill: "red", shape: "ring", text: "error"}); if (done) { done(lastError); } } };