@gravypower/node-red-franklinwh
Version:
Node-RED node to control FranklinWH gateway
238 lines (207 loc) • 9.44 kB
JavaScript
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);
}
}
};