@hiveio/dhive 1.3.4 → 1.3.6

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.
@@ -38,6 +38,12 @@ export interface HealthTrackerOptions {
38
38
  * Default: 2 minutes.
39
39
  */
40
40
  headBlockTtlMs?: number;
41
+ /**
42
+ * Default duration (ms) to skip a node after receiving a 429 response,
43
+ * used when the server doesn't provide a Retry-After header.
44
+ * Default: 10 seconds.
45
+ */
46
+ defaultRateLimitMs?: number;
41
47
  }
42
48
  export declare class NodeHealthTracker {
43
49
  private health;
@@ -49,6 +55,7 @@ export declare class NodeHealthTracker {
49
55
  private readonly maxApiFailuresBeforeCooldown;
50
56
  private readonly staleBlockThreshold;
51
57
  private readonly headBlockTtlMs;
58
+ private readonly defaultRateLimitMs;
52
59
  constructor(options?: HealthTrackerOptions);
53
60
  private getOrCreate;
54
61
  /**
@@ -61,6 +68,16 @@ export declare class NodeHealthTracker {
61
68
  * Increments both the global consecutive failure counter and the API-specific counter.
62
69
  */
63
70
  recordFailure(node: string, api: string): void;
71
+ /**
72
+ * Record that a node returned HTTP 429 (Too Many Requests).
73
+ * The node will be skipped until the rate limit expires.
74
+ * @param retryAfterSeconds Value from the Retry-After header, or undefined to use default.
75
+ */
76
+ recordRateLimit(node: string, retryAfterSeconds?: number): void;
77
+ /**
78
+ * Check if a node is currently rate-limited (429 cooldown active).
79
+ */
80
+ isRateLimited(node: string): boolean;
64
81
  /**
65
82
  * Record an API/plugin-specific failure (e.g. "method not found", "plugin not enabled").
66
83
  * Only increments the per-API counter, NOT the global consecutive failure counter.
@@ -11,7 +11,7 @@
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  class NodeHealthTracker {
13
13
  constructor(options = {}) {
14
- var _a, _b, _c, _d, _e, _f;
14
+ var _a, _b, _c, _d, _e, _f, _g;
15
15
  this.health = new Map();
16
16
  this.bestKnownHeadBlock = 0;
17
17
  this.bestKnownHeadBlockTime = 0;
@@ -21,6 +21,7 @@ class NodeHealthTracker {
21
21
  this.maxApiFailuresBeforeCooldown = (_d = options.maxApiFailuresBeforeCooldown) !== null && _d !== void 0 ? _d : 2;
22
22
  this.staleBlockThreshold = (_e = options.staleBlockThreshold) !== null && _e !== void 0 ? _e : 30;
23
23
  this.headBlockTtlMs = (_f = options.headBlockTtlMs) !== null && _f !== void 0 ? _f : 120000;
24
+ this.defaultRateLimitMs = (_g = options.defaultRateLimitMs) !== null && _g !== void 0 ? _g : 10000;
24
25
  }
25
26
  getOrCreate(node) {
26
27
  let state = this.health.get(node);
@@ -55,6 +56,29 @@ class NodeHealthTracker {
55
56
  state.lastFailure = Date.now();
56
57
  this.incrementApiFailure(state, api);
57
58
  }
59
+ /**
60
+ * Record that a node returned HTTP 429 (Too Many Requests).
61
+ * The node will be skipped until the rate limit expires.
62
+ * @param retryAfterSeconds Value from the Retry-After header, or undefined to use default.
63
+ */
64
+ recordRateLimit(node, retryAfterSeconds) {
65
+ const state = this.getOrCreate(node);
66
+ const delayMs = retryAfterSeconds != null
67
+ ? retryAfterSeconds * 1000
68
+ : this.defaultRateLimitMs;
69
+ state.rateLimit = { retryAfter: Date.now() + delayMs };
70
+ state.consecutiveFailures++;
71
+ state.lastFailure = Date.now();
72
+ }
73
+ /**
74
+ * Check if a node is currently rate-limited (429 cooldown active).
75
+ */
76
+ isRateLimited(node) {
77
+ const state = this.health.get(node);
78
+ if (!(state === null || state === void 0 ? void 0 : state.rateLimit))
79
+ return false;
80
+ return Date.now() < state.rateLimit.retryAfter;
81
+ }
58
82
  /**
59
83
  * Record an API/plugin-specific failure (e.g. "method not found", "plugin not enabled").
60
84
  * Only increments the per-API counter, NOT the global consecutive failure counter.
@@ -93,6 +117,10 @@ class NodeHealthTracker {
93
117
  if (!state)
94
118
  return true; // Unknown nodes are assumed healthy
95
119
  const now = Date.now();
120
+ // Check rate-limit cooldown (429 received)
121
+ if (state.rateLimit && now < state.rateLimit.retryAfter) {
122
+ return false;
123
+ }
96
124
  // Check overall node health (consecutive failures)
97
125
  if (state.consecutiveFailures >= this.maxFailuresBeforeCooldown) {
98
126
  if (now - state.lastFailure < this.nodeCooldownMs) {
package/lib/utils.js CHANGED
@@ -155,7 +155,7 @@ function nextNode(nodes, currentIndex) {
155
155
  * - NEVER retry after timeout or response errors to prevent double-broadcasting
156
156
  */
157
157
  function retryingFetch(currentAddress, allAddresses, opts, timeout, failoverThreshold, consoleOnFailover, backoff, fetchTimeout, retryContext) {
158
- var _a;
158
+ var _a, _b, _c;
159
159
  return __awaiter(this, void 0, void 0, function* () {
160
160
  const { healthTracker, api, isBroadcast } = retryContext || {};
161
161
  const logFailover = (_a = retryContext === null || retryContext === void 0 ? void 0 : retryContext.consoleOnFailover) !== null && _a !== void 0 ? _a : consoleOnFailover;
@@ -182,26 +182,51 @@ function retryingFetch(currentAddress, allAddresses, opts, timeout, failoverThre
182
182
  while (true) {
183
183
  const node = orderedNodes[nodeIndex];
184
184
  try {
185
+ // Skip nodes that are currently rate-limited
186
+ if (healthTracker && healthTracker.isRateLimited(node)) {
187
+ lastError = new Error(`Node ${node} is rate-limited, skipping`);
188
+ if (healthTracker && api)
189
+ healthTracker.recordFailure(node, api);
190
+ throw lastError;
191
+ }
185
192
  if (fetchTimeout) {
186
193
  opts.timeout = fetchTimeout(nodesTriedInRound);
187
194
  }
188
195
  const response = yield cross_fetch_1.default(node, opts);
189
196
  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
- }
197
+ // Handle 429 record rate limit and fail over immediately
198
+ if (response.status === 429) {
199
+ const retryAfterHeader = (_c = (_b = response.headers) === null || _b === void 0 ? void 0 : _b.get) === null || _c === void 0 ? void 0 : _c.call(_b, 'retry-after');
200
+ const retryAfterSec = retryAfterHeader ? parseInt(retryAfterHeader, 10) : undefined;
201
+ if (healthTracker) {
202
+ healthTracker.recordRateLimit(node, !isNaN(retryAfterSec) ? retryAfterSec : undefined);
199
203
  }
200
- catch (_b) {
201
- // JSON parse failed, fall through to error handling
204
+ throw new Error(`HTTP 429: Too Many Requests`);
205
+ }
206
+ // Handle 503 — don't parse JSON body, just fail over
207
+ if (response.status === 503) {
208
+ if (healthTracker && api)
209
+ healthTracker.recordFailure(node, api);
210
+ throw new Error(`HTTP 503: Service Temporarily Unavailable`);
211
+ }
212
+ // Some Hive nodes return non-200 HTTP status (500, 502, etc.)
213
+ // but still include a valid JSON-RPC response in the body.
214
+ // This happens when a node is overloaded — it processes the transaction
215
+ // but returns an error HTTP status. For broadcasts, ignoring the body
216
+ // would cause the caller to think it failed, leading to double-posts.
217
+ try {
218
+ const resJson = yield response.json();
219
+ if (resJson.jsonrpc === '2.0') {
220
+ if (healthTracker && api)
221
+ healthTracker.recordSuccess(node, api);
222
+ return { response: resJson, currentAddress: node };
202
223
  }
203
224
  }
204
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
225
+ catch (_d) {
226
+ // JSON parse failed, fall through to error handling
227
+ }
228
+ const statusText = response.statusText || `status code ${response.status}`;
229
+ throw new Error(`HTTP ${response.status}: ${statusText}`);
205
230
  }
206
231
  const responseJson = yield response.json();
207
232
  // Record success in health tracker
@@ -244,6 +269,11 @@ function retryingFetch(currentAddress, allAddresses, opts, timeout, failoverThre
244
269
  // Unrecognized error type — don't failover, throw immediately
245
270
  throw error;
246
271
  }
272
+ // Small delay between node attempts within a round to prevent
273
+ // flooding all nodes when multiple concurrent requests fail over
274
+ if (totalNodes > 1 && nodesTriedInRound > 0) {
275
+ yield sleep(50 + Math.random() * 50); // 50-100ms jitter
276
+ }
247
277
  // Try next node immediately (no backoff within a round)
248
278
  if (totalNodes > 1) {
249
279
  nodeIndex = nextNode(orderedNodes, nodeIndex);
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.3';
3
+ exports.default = '1.3.6';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hiveio/dhive",
3
- "version": "1.3.4",
3
+ "version": "1.3.6",
4
4
  "description": "Hive blockchain RPC client library",
5
5
  "author": "hive-network",
6
6
  "license": "BSD-3-Clause-No-Military-License",