@hiveio/dhive 1.3.1-beta → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/utils.js CHANGED
@@ -52,8 +52,10 @@ var __asyncValues = (this && this.__asyncValues) || function (o) {
52
52
  Object.defineProperty(exports, "__esModule", { value: true });
53
53
  const cross_fetch_1 = require("cross-fetch");
54
54
  const stream_1 = require("stream");
55
- // TODO: Add more errors that should trigger a failover
56
- const timeoutErrors = ['timeout', 'ENOTFOUND', 'ECONNREFUSED', 'database lock', 'CERT_HAS_EXPIRED', 'EHOSTUNREACH', 'ECONNRESET', 'ERR_TLS_CERT_ALTNAME_INVALID'];
55
+ // Errors that indicate the request never reached the server — safe to retry even for broadcasts
56
+ const PRE_CONNECTION_ERRORS = ['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH', 'EAI_AGAIN'];
57
+ // All errors that should trigger failover for read operations
58
+ const FAILOVER_ERRORS = [...PRE_CONNECTION_ERRORS, 'timeout', 'database lock', 'CERT_HAS_EXPIRED', 'ECONNRESET', 'ERR_TLS_CERT_ALTNAME_INVALID', 'ETIMEDOUT', 'EPIPE', 'EPROTO'];
57
59
  /**
58
60
  * Return a promise that will resove when a specific event is emitted.
59
61
  */
@@ -114,70 +116,175 @@ function copy(object) {
114
116
  }
115
117
  exports.copy = copy;
116
118
  /**
117
- * Fetch API wrapper that retries until timeout is reached.
119
+ * Check if an error code indicates the request never reached the server.
118
120
  */
119
- function retryingFetch(currentAddress, allAddresses, opts, timeout, failoverThreshold, consoleOnFailover, backoff, fetchTimeout) {
121
+ function isPreConnectionError(error) {
122
+ if (!error || !error.code)
123
+ return false;
124
+ return PRE_CONNECTION_ERRORS.some((code) => error.code.includes(code));
125
+ }
126
+ /**
127
+ * Check if an error should trigger failover for read operations.
128
+ * Matches any known network/timeout error, or errors with no code (HTTP errors).
129
+ */
130
+ function shouldFailover(error) {
131
+ if (!error)
132
+ return true;
133
+ // HTTP errors (from !response.ok) have no .code — they should trigger failover
134
+ if (!error.code)
135
+ return true;
136
+ return FAILOVER_ERRORS.some((code) => error.code.includes(code));
137
+ }
138
+ /**
139
+ * Get the next node in the ordered list (wraps around).
140
+ */
141
+ function nextNode(nodes, currentIndex) {
142
+ return (currentIndex + 1) % nodes.length;
143
+ }
144
+ /**
145
+ * Smart fetch with immediate failover and per-node health tracking.
146
+ *
147
+ * For read operations:
148
+ * - On failure, immediately try the next healthy node (no backoff within a round)
149
+ * - After trying all nodes once (one round), apply backoff before the next round
150
+ * - Stop after failoverThreshold rounds
151
+ *
152
+ * For broadcast operations:
153
+ * - Only retry on pre-connection errors (ECONNREFUSED, ENOTFOUND, etc.)
154
+ * where we know the request never reached the server
155
+ * - NEVER retry after timeout or response errors to prevent double-broadcasting
156
+ */
157
+ function retryingFetch(currentAddress, allAddresses, opts, timeout, failoverThreshold, consoleOnFailover, backoff, fetchTimeout, retryContext) {
158
+ var _a;
120
159
  return __awaiter(this, void 0, void 0, function* () {
121
- let start = Date.now();
122
- let tries = 0;
160
+ const { healthTracker, api, isBroadcast } = retryContext || {};
161
+ const logFailover = (_a = retryContext === null || retryContext === void 0 ? void 0 : retryContext.consoleOnFailover) !== null && _a !== void 0 ? _a : consoleOnFailover;
162
+ // Build ordered node list: healthy nodes first, then unhealthy as fallback
163
+ let orderedNodes;
164
+ if (Array.isArray(allAddresses) && allAddresses.length > 1) {
165
+ orderedNodes = healthTracker
166
+ ? healthTracker.getOrderedNodes(allAddresses, api)
167
+ : [...allAddresses];
168
+ }
169
+ else {
170
+ orderedNodes = Array.isArray(allAddresses) ? allAddresses : [allAddresses];
171
+ }
172
+ // Always start from the healthiest node (index 0 of the ordered list).
173
+ // The health tracker already sorted nodes with healthy ones first,
174
+ // so starting from 0 ensures we use the best available node.
175
+ let nodeIndex = 0;
176
+ const totalNodes = orderedNodes.length;
177
+ const startTime = Date.now();
178
+ let nodesTriedInRound = 0;
123
179
  let round = 0;
124
- do {
180
+ let lastError;
181
+ // tslint:disable-next-line: no-constant-condition
182
+ while (true) {
183
+ const node = orderedNodes[nodeIndex];
125
184
  try {
126
185
  if (fetchTimeout) {
127
- opts.timeout = fetchTimeout(tries);
186
+ opts.timeout = fetchTimeout(nodesTriedInRound);
128
187
  }
129
- const response = yield cross_fetch_1.default(currentAddress, opts);
188
+ const response = yield cross_fetch_1.default(node, opts);
130
189
  if (!response.ok) {
190
+ // Support for Drone: HTTP 500 with valid JSON-RPC response
191
+ if (response.status === 500) {
192
+ try {
193
+ const resJson = yield response.json();
194
+ if (resJson.jsonrpc === '2.0') {
195
+ if (healthTracker && api)
196
+ healthTracker.recordSuccess(node, api);
197
+ return { response: resJson, currentAddress: node };
198
+ }
199
+ }
200
+ catch (_b) {
201
+ // JSON parse failed, fall through to error handling
202
+ }
203
+ }
131
204
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
132
205
  }
133
- return { response: yield response.json(), currentAddress };
206
+ const responseJson = yield response.json();
207
+ // Record success in health tracker
208
+ if (healthTracker && api) {
209
+ healthTracker.recordSuccess(node, api);
210
+ }
211
+ return { response: responseJson, currentAddress: node };
134
212
  }
135
213
  catch (error) {
136
- if (timeout !== 0 && Date.now() - start > timeout) {
137
- if ((!error || !error.code) && Array.isArray(allAddresses)) {
138
- // If error is empty or not code is present, it means rpc is down => switch
139
- currentAddress = failover(currentAddress, allAddresses, currentAddress, consoleOnFailover);
214
+ lastError = error;
215
+ // Record failure in health tracker
216
+ if (healthTracker && api) {
217
+ healthTracker.recordFailure(node, api);
218
+ }
219
+ // === BROADCAST SAFETY ===
220
+ // For broadcasts, only retry if the request definitely never reached the server.
221
+ // If there's any chance the server received it, throw immediately to prevent
222
+ // double-broadcasting (e.g. double transfers, double votes).
223
+ if (isBroadcast) {
224
+ if (isPreConnectionError(error) && totalNodes > 1) {
225
+ // Safe to try another node — request never left the client
226
+ nodeIndex = nextNode(orderedNodes, nodeIndex);
227
+ nodesTriedInRound++;
228
+ if (nodesTriedInRound >= totalNodes) {
229
+ // Tried all nodes, give up for broadcasts
230
+ throw error;
231
+ }
232
+ if (logFailover) {
233
+ // tslint:disable-next-line: no-console
234
+ console.log(`Broadcast failover to: ${orderedNodes[nodeIndex]} (${error.code}, request never sent)`);
235
+ }
236
+ continue;
140
237
  }
141
- else {
142
- const isFailoverError = timeoutErrors.filter((fe) => error && error.code && error.code.includes(fe)).length > 0;
143
- if (isFailoverError &&
144
- Array.isArray(allAddresses) &&
145
- allAddresses.length > 1) {
146
- if (round < failoverThreshold) {
147
- start = Date.now();
148
- tries = -1;
149
- if (failoverThreshold > 0) {
150
- round++;
151
- }
152
- currentAddress = failover(currentAddress, allAddresses, currentAddress, consoleOnFailover);
153
- }
154
- else {
155
- error.message = `[${error.code}] tried ${failoverThreshold} times with ${allAddresses.join(',')}`;
238
+ // Timeout, HTTP error, or unknown error — request may have been received.
239
+ // Do NOT retry. Throw immediately.
240
+ throw error;
241
+ }
242
+ // === READ OPERATION FAILOVER ===
243
+ if (!shouldFailover(error)) {
244
+ // Unrecognized error type — don't failover, throw immediately
245
+ throw error;
246
+ }
247
+ // Try next node immediately (no backoff within a round)
248
+ if (totalNodes > 1) {
249
+ nodeIndex = nextNode(orderedNodes, nodeIndex);
250
+ nodesTriedInRound++;
251
+ if (nodesTriedInRound >= totalNodes) {
252
+ // Completed a full round through all nodes
253
+ nodesTriedInRound = 0;
254
+ // failoverThreshold=0 means retry forever (only timeout can stop it)
255
+ if (failoverThreshold > 0) {
256
+ round++;
257
+ if (round >= failoverThreshold) {
258
+ error.message = `All ${totalNodes} nodes failed after ${failoverThreshold} rounds. ` +
259
+ `Last error: [${error.code || 'HTTP'}] ${error.message}. ` +
260
+ `Nodes: ${orderedNodes.join(', ')}`;
156
261
  throw error;
157
262
  }
158
263
  }
159
- else {
160
- // tslint:disable-next-line: no-console
161
- console.error(`Didn't failover for error ${error.code ? 'code' : 'message'}: [${error.code || error.message}]`);
264
+ // Check total timeout before starting next round
265
+ if (timeout !== 0 && Date.now() - startTime > timeout) {
162
266
  throw error;
163
267
  }
268
+ // Backoff between rounds (not between individual node attempts)
269
+ yield sleep(backoff(round));
270
+ }
271
+ if (logFailover) {
272
+ // tslint:disable-next-line: no-console
273
+ console.log(`Switched Hive RPC: ${orderedNodes[nodeIndex]} (previous: ${node}, error: ${error.code || error.message})`);
274
+ }
275
+ }
276
+ else {
277
+ // Single node: use backoff and retry same node (legacy behavior)
278
+ if (timeout !== 0 && Date.now() - startTime > timeout) {
279
+ throw error;
164
280
  }
281
+ yield sleep(backoff(nodesTriedInRound++));
165
282
  }
166
- yield sleep(backoff(tries++));
167
283
  }
168
- } while (true);
284
+ }
169
285
  });
170
286
  }
171
287
  exports.retryingFetch = retryingFetch;
172
- const failover = (url, urls, currentAddress, consoleOnFailover) => {
173
- const index = urls.indexOf(url);
174
- const targetUrl = urls.length === index + 1 ? urls[0] : urls[index + 1];
175
- if (consoleOnFailover) {
176
- // tslint:disable-next-line: no-console
177
- console.log(`Switched Hive RPC: ${targetUrl} (previous: ${currentAddress})`);
178
- }
179
- return targetUrl;
180
- };
181
288
  // Hack to be able to generate a valid witness_set_properties op
182
289
  // Can hopefully be removed when hived's JSON representation is fixed
183
290
  const ByteBuffer = require("@ecency/bytebuffer");
package/lib/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.default = '1.3.0';
3
+ exports.default = '1.3.3';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hiveio/dhive",
3
- "version": "1.3.1-beta",
3
+ "version": "1.3.3",
4
4
  "description": "Hive blockchain RPC client library",
5
5
  "author": "hive-network",
6
6
  "license": "BSD-3-Clause-No-Military-License",