From 9857433246cced6757ff8848e7875921ed5c9d5c Mon Sep 17 00:00:00 2001 From: Chris Chase Date: Thu, 5 Mar 2026 11:12:15 +1030 Subject: [PATCH] Add configurable max retries with exponential backoff --- README.md | 4 +- odbcwritenow.html | 13 +++++- odbcwritenow.js | 104 +++++++++++++++++++++++++++------------------- 3 files changed, 76 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 61f5e49..8f3dfc5 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ Editor fields: - `Name` (optional) - `What` (required): dataset/resource name, for example `sales_invoice_item` - `OrderBy` (optional): default sort expression +- `MaxRetries` (optional, default `3`): number of retries for timeout/token errors +- `RetryBackoffMs` (optional, default `500`): base delay in milliseconds; each retry uses exponential backoff (`base * 2^attempt`) - `APIKey` (required unless provided in `msg.apikey`) ## Runtime Inputs (`msg`) @@ -78,7 +80,7 @@ You can override behavior per message: ## Notes - Base URL is currently fixed to `https://myobsync.accede.com.au`. -- Timeout and token error responses are retried recursively. +- Timeout and token error responses are retried using bounded exponential backoff. ## License diff --git a/odbcwritenow.html b/odbcwritenow.html index b8074e6..932a543 100644 --- a/odbcwritenow.html +++ b/odbcwritenow.html @@ -6,7 +6,9 @@ name: {value:""}, apikey: {value:""}, what: {value:""}, - orderby: {value:""} + orderby: {value:""}, + maxRetries: {value:3}, + retryBackoffMs: {value:500} }, inputs: 1, outputs: 2, @@ -30,6 +32,14 @@ +
+ + +
+
+ + +
@@ -39,4 +49,3 @@ - diff --git a/odbcwritenow.js b/odbcwritenow.js index dd735c4..e60a507 100644 --- a/odbcwritenow.js +++ b/odbcwritenow.js @@ -1,48 +1,64 @@ -async function DoImport(msg, url, node) { +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function DoImport(msg, url, node, maxRetries, baseBackoffMs) { console.log('>', url) msg.nodata = false delete msg.complete - node.status({ fill: "blue", shape: "ring", text: `Fetching #${msg.page}: ${msg.what}` }) - var datastr = "" - try { - const response = await fetch(url); + let datastr = "" + let attempt = 0 - datastr = await response.text(); - if (datastr.toLowerCase().includes("no data found")) { - console.log("no data found"); - msg.nodata = true - msg.complete = true - msg.payload = [] - node.status({ fill: "green", shape: "ring", text: `#${msg.page}: No data found` }) - return node.send([null, msg]); - } + while (attempt <= maxRetries) { + msg.retry = attempt + node.status({ fill: "blue", shape: "ring", text: `Fetching #${msg.page}: ${msg.what} (try ${attempt + 1}/${maxRetries + 1})` }) - //Errors and retrys etc - if (datastr.toLowerCase().includes("timeout")) { - node.status({ fill: "red", shape: "ring", text: `#${msg.page}: MYOB Gateway Timeout` }) - console.error("******* MYOB Gateway Timeout", msg.retry, msg.page, url) - msg.retry = msg.retry + 1 - return await DoImport(msg, url, node) - } - if (datastr.toLowerCase().includes("token error")) { - node.status({ fill: "red", shape: "ring", text: `#${msg.page}: MYOB Token Error` }) - console.error("******* MYOB Token Error", msg.retry, msg.page, url) - msg.retry = msg.retry + 1 - return await DoImport(msg, url, node) - } - //--------------------------------- - //Everything looks good - const data = JSON.parse(datastr); - msg.rows = data.length - console.log("Page:", data.page, "Rows:", data.length) + try { + const response = await fetch(url); + datastr = await response.text(); - node.status({ fill: "green", shape: "dot", text: `#${msg.page}: ${msg.rows} Rows` }) - msg.payload = data; - node.send([msg, null]); - } catch (error) { - node.status({ fill: "red", shape: "ring", text: error.message }) - console.error(error.message) - console.log(datastr) + if (datastr.toLowerCase().includes("no data found")) { + console.log("no data found"); + msg.nodata = true + msg.complete = true + msg.payload = [] + node.status({ fill: "green", shape: "ring", text: `#${msg.page}: No data found` }) + return node.send([null, msg]); + } + + // Retry only for gateway timeout and token error responses. + if (datastr.toLowerCase().includes("timeout") || datastr.toLowerCase().includes("token error")) { + const isLastTry = attempt >= maxRetries + const kind = datastr.toLowerCase().includes("timeout") ? "MYOB Gateway Timeout" : "MYOB Token Error" + + if (isLastTry) { + node.status({ fill: "red", shape: "ring", text: `#${msg.page}: ${kind} (max retries reached)` }) + return node.error(`${kind} after ${maxRetries + 1} attempts`, msg) + } + + const delayMs = Math.max(0, baseBackoffMs) * Math.pow(2, attempt) + node.status({ fill: "red", shape: "ring", text: `#${msg.page}: ${kind}, retrying in ${delayMs}ms` }) + console.error("*******", kind, attempt, msg.page, `retry in ${delayMs}ms`) + attempt += 1 + await sleep(delayMs) + continue + } + + //--------------------------------- + // Everything looks good. + const data = JSON.parse(datastr); + msg.rows = data.length + console.log("Page:", msg.page, "Rows:", data.length) + + node.status({ fill: "green", shape: "dot", text: `#${msg.page}: ${msg.rows} Rows` }) + msg.payload = data; + return node.send([msg, null]); + } catch (error) { + node.status({ fill: "red", shape: "ring", text: error.message }) + console.error(error.message) + console.log(datastr) + return + } } } @@ -63,7 +79,12 @@ module.exports = function(RED) { msg.page = page msg.what = what - msg.retry = 0 + const maxRetries = Number.isInteger(parseInt(config.maxRetries, 10)) + ? Math.max(0, parseInt(config.maxRetries, 10)) + : 3 + const backoffMs = Number.isInteger(parseInt(config.retryBackoffMs, 10)) + ? Math.max(0, parseInt(config.retryBackoffMs, 10)) + : 500 var orderbystr = ""; var filtersstr = ""; @@ -87,11 +108,10 @@ module.exports = function(RED) { } const url = `https://myobsync.accede.com.au/download/${what}/json/${page}?apikey=${apikey}${filtersstr}${orderbystr}`; - DoImport(msg, url, node) + DoImport(msg, url, node, maxRetries, backoffMs) }); } RED.nodes.registerType("odbcwritenow-get", ODBCWriteNowGet); } -