ethers-rpc-pool 1.1.3 → 3.2.0
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/README.md +336 -89
- package/dist/index.cjs +282 -143
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +57 -71
- package/dist/index.d.ts +57 -71
- package/dist/index.js +282 -143
- package/dist/index.js.map +1 -1
- package/package.json +13 -3
package/dist/index.cjs
CHANGED
|
@@ -35,6 +35,7 @@ var Stats = class {
|
|
|
35
35
|
_perProviderMethod = {};
|
|
36
36
|
_rateLimitedTotal = 0;
|
|
37
37
|
_timeoutTotal = 0;
|
|
38
|
+
_serverErrorTotal = 0;
|
|
38
39
|
_rpcErrorTotal = 0;
|
|
39
40
|
_perProviderInFlight = {};
|
|
40
41
|
_perProviderTotal = {};
|
|
@@ -43,79 +44,55 @@ var Stats = class {
|
|
|
43
44
|
_perProviderError = {};
|
|
44
45
|
_perProviderRpcError = {};
|
|
45
46
|
_perMethodRpcError = {};
|
|
46
|
-
_providerCooldownUntil = {};
|
|
47
47
|
_bump(map, key) {
|
|
48
48
|
map[key] = (map[key] || 0) + 1;
|
|
49
49
|
}
|
|
50
50
|
_decrease(map, key) {
|
|
51
51
|
map[key] = Math.max((map[key] || 0) - 1, 0);
|
|
52
52
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
_bumpRpcErrorTotal() {
|
|
66
|
-
this._rpcErrorTotal++;
|
|
67
|
-
}
|
|
68
|
-
bumpInFlightPerProvider(id) {
|
|
69
|
-
this._bumpInFlight();
|
|
70
|
-
this._bump(this._perProviderInFlight, id);
|
|
71
|
-
}
|
|
72
|
-
decreaseInFlightPerProvider(id) {
|
|
73
|
-
this.decreaseInFlight();
|
|
74
|
-
this._decrease(this._perProviderInFlight, id);
|
|
75
|
-
}
|
|
76
|
-
decreaseInFlight() {
|
|
53
|
+
onEvent(e) {
|
|
54
|
+
const id = e.providerId;
|
|
55
|
+
if (e.type === "request") {
|
|
56
|
+
this._inFlight++;
|
|
57
|
+
this._bump(this._perProviderInFlight, id);
|
|
58
|
+
this._total++;
|
|
59
|
+
this._perProviderTotal[id] = (this._perProviderTotal[id] || 0) + 1;
|
|
60
|
+
this._bump(this._perMethod, e.method);
|
|
61
|
+
if (!this._perProviderMethod[id]) this._perProviderMethod[id] = {};
|
|
62
|
+
this._bump(this._perProviderMethod[id], e.method);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
77
65
|
this._inFlight = Math.max(this._inFlight - 1, 0);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
66
|
+
this._decrease(this._perProviderInFlight, id);
|
|
67
|
+
if (e.type === "error") {
|
|
68
|
+
if (e.isRateLimit) {
|
|
69
|
+
this._rateLimitedTotal++;
|
|
70
|
+
this._bump(this._perProviderRateLimited, id);
|
|
71
|
+
} else if (e.isTimeout) {
|
|
72
|
+
this._timeoutTotal++;
|
|
73
|
+
this._bump(this._perProviderTimeout, id);
|
|
74
|
+
} else if (e.status !== void 0 && e.status >= 500) {
|
|
75
|
+
this._serverErrorTotal++;
|
|
76
|
+
this._bump(this._perProviderError, id);
|
|
77
|
+
}
|
|
83
78
|
}
|
|
84
|
-
this._bump(this._perProviderMethod[id], method);
|
|
85
|
-
}
|
|
86
|
-
bumpRateLimitedPerProvider(id) {
|
|
87
|
-
this._bumpRateLimitedTotal();
|
|
88
|
-
this._bump(this._perProviderRateLimited, id);
|
|
89
|
-
}
|
|
90
|
-
bumpTimeoutPerProvider(id) {
|
|
91
|
-
this._bumpTimeoutTotal();
|
|
92
|
-
this._bump(this._perProviderTimeout, id);
|
|
93
|
-
}
|
|
94
|
-
bumpProviderTotal(id) {
|
|
95
|
-
this._bumpTotal();
|
|
96
|
-
this._perProviderTotal[id] = (this._perProviderTotal[id] || 0) + 1;
|
|
97
|
-
}
|
|
98
|
-
bumpServerErrorPerProvider(id) {
|
|
99
|
-
this._bump(this._perProviderError, id);
|
|
100
79
|
}
|
|
101
80
|
bumpRpcError(providerId, method) {
|
|
102
|
-
this.
|
|
81
|
+
this._rpcErrorTotal++;
|
|
103
82
|
if (!this._perProviderRpcError[providerId]) {
|
|
104
83
|
this._perProviderRpcError[providerId] = {};
|
|
105
84
|
}
|
|
106
85
|
this._bump(this._perProviderRpcError[providerId], method);
|
|
107
86
|
this._bump(this._perMethodRpcError, method);
|
|
108
87
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
setCooldown(id, ms) {
|
|
118
|
-
this._providerCooldownUntil[id] = Date.now() + ms;
|
|
88
|
+
removeProvider(id) {
|
|
89
|
+
delete this._perProviderInFlight[id];
|
|
90
|
+
delete this._perProviderTotal[id];
|
|
91
|
+
delete this._perProviderTimeout[id];
|
|
92
|
+
delete this._perProviderRateLimited[id];
|
|
93
|
+
delete this._perProviderError[id];
|
|
94
|
+
delete this._perProviderRpcError[id];
|
|
95
|
+
delete this._perProviderMethod[id];
|
|
119
96
|
}
|
|
120
97
|
snapshot() {
|
|
121
98
|
return {
|
|
@@ -124,16 +101,20 @@ var Stats = class {
|
|
|
124
101
|
perMethodTotal: { ...this._perMethod },
|
|
125
102
|
rateLimitedTotal: this._rateLimitedTotal,
|
|
126
103
|
timeoutTotal: this._timeoutTotal,
|
|
104
|
+
serverErrorTotal: this._serverErrorTotal,
|
|
127
105
|
rpcErrorTotal: this._rpcErrorTotal,
|
|
128
|
-
perProviderMethod:
|
|
106
|
+
perProviderMethod: Object.fromEntries(
|
|
107
|
+
Object.entries(this._perProviderMethod).map(([k, v]) => [k, { ...v }])
|
|
108
|
+
),
|
|
129
109
|
perProviderInFlight: { ...this._perProviderInFlight },
|
|
130
110
|
perProviderRateLimited: { ...this._perProviderRateLimited },
|
|
131
111
|
perProviderTimeout: { ...this._perProviderTimeout },
|
|
132
112
|
perProviderError: { ...this._perProviderError },
|
|
133
|
-
perProviderRpcError:
|
|
113
|
+
perProviderRpcError: Object.fromEntries(
|
|
114
|
+
Object.entries(this._perProviderRpcError).map(([k, v]) => [k, { ...v }])
|
|
115
|
+
),
|
|
134
116
|
perMethodRpcError: { ...this._perMethodRpcError },
|
|
135
|
-
perProviderTotal: { ...this._perProviderTotal }
|
|
136
|
-
providerCooldownUntil: { ...this._providerCooldownUntil }
|
|
117
|
+
perProviderTotal: { ...this._perProviderTotal }
|
|
137
118
|
};
|
|
138
119
|
}
|
|
139
120
|
};
|
|
@@ -165,8 +146,12 @@ function isTimeoutError(e) {
|
|
|
165
146
|
const msg = String(e?.message || e);
|
|
166
147
|
return /timeout|timed out|ETIMEDOUT|ESOCKETTIMEDOUT|ECONNABORTED|504 Gateway/i.test(msg);
|
|
167
148
|
}
|
|
149
|
+
var NETWORK_ERROR_CODES = /* @__PURE__ */ new Set(["ECONNREFUSED", "ECONNRESET", "ENOTFOUND", "EAI_AGAIN"]);
|
|
150
|
+
function isNetworkError(e) {
|
|
151
|
+
return NETWORK_ERROR_CODES.has(e?.code);
|
|
152
|
+
}
|
|
168
153
|
function isTransportError(e) {
|
|
169
|
-
return isTimeoutError(e) || isRateLimitError(e) || isServerError(e);
|
|
154
|
+
return isTimeoutError(e) || isRateLimitError(e) || isServerError(e) || isNetworkError(e);
|
|
170
155
|
}
|
|
171
156
|
function isRpcLogicalError(e) {
|
|
172
157
|
if (isTransportError(e)) return false;
|
|
@@ -179,10 +164,7 @@ function isRpcLogicalError(e) {
|
|
|
179
164
|
);
|
|
180
165
|
}
|
|
181
166
|
function shouldFailover(e) {
|
|
182
|
-
|
|
183
|
-
const rl = isRateLimitError(e);
|
|
184
|
-
const se = isServerError(e);
|
|
185
|
-
return to || rl || se;
|
|
167
|
+
return isTimeoutError(e) || isRateLimitError(e) || isServerError(e) || isNetworkError(e);
|
|
186
168
|
}
|
|
187
169
|
|
|
188
170
|
// src/InstrumentedProvider.ts
|
|
@@ -257,20 +239,16 @@ var RpsLimiter = class {
|
|
|
257
239
|
return this.tokens >= count;
|
|
258
240
|
}
|
|
259
241
|
// Take count tokens (usually 1 request = 1 token).
|
|
260
|
-
// If not enough tokens —
|
|
242
|
+
// If not enough tokens — enqueue exactly one setTimeout for the precise wake-up time.
|
|
243
|
+
// tokens may go negative (debt); each caller's wait time is derived from that debt.
|
|
261
244
|
async take(count = 1) {
|
|
262
245
|
if (!this.rps || this.rps <= 0) return;
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
const need = count - this.tokens;
|
|
271
|
-
const waitMs = Math.ceil(need / this.rps * 1e3);
|
|
272
|
-
await new Promise((r) => setTimeout(r, Math.min(waitMs, 50)));
|
|
273
|
-
}
|
|
246
|
+
const now = Date.now();
|
|
247
|
+
this.refill(now);
|
|
248
|
+
this.tokens -= count;
|
|
249
|
+
if (this.tokens >= 0) return;
|
|
250
|
+
const waitMs = Math.ceil(-this.tokens / this.rps * 1e3);
|
|
251
|
+
return new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
274
252
|
}
|
|
275
253
|
};
|
|
276
254
|
|
|
@@ -281,9 +259,7 @@ var InstrumentedJsonRpcProvider = class extends import_ethers.JsonRpcProvider {
|
|
|
281
259
|
options;
|
|
282
260
|
inFlightLimiter;
|
|
283
261
|
rpsLimiter;
|
|
284
|
-
stats;
|
|
285
262
|
fetchRequest;
|
|
286
|
-
lastCooldownMs = 0;
|
|
287
263
|
constructor(url, network, options) {
|
|
288
264
|
let fetchRequest;
|
|
289
265
|
if (typeof url == "string") {
|
|
@@ -301,29 +277,23 @@ var InstrumentedJsonRpcProvider = class extends import_ethers.JsonRpcProvider {
|
|
|
301
277
|
const { rps = 10, rpsBurst, inFlight = 1 } = options;
|
|
302
278
|
this.inFlightLimiter = new Semaphore(inFlight);
|
|
303
279
|
this.rpsLimiter = new RpsLimiter(rps, rpsBurst || rps);
|
|
304
|
-
this.stats = options.stats;
|
|
305
280
|
}
|
|
306
281
|
async _send(payload) {
|
|
307
282
|
await this.rpsLimiter.take(1);
|
|
308
|
-
const release =
|
|
283
|
+
const release = await this.inFlightLimiter.acquire();
|
|
309
284
|
try {
|
|
310
285
|
return await this._sendInstrumented(payload);
|
|
311
286
|
} finally {
|
|
312
|
-
release
|
|
287
|
+
release();
|
|
313
288
|
}
|
|
314
289
|
}
|
|
315
290
|
isAvailable(count = 1) {
|
|
316
291
|
return this.rpsLimiter.isAvailable(count) && this.inFlightLimiter.isAvailable();
|
|
317
292
|
}
|
|
318
|
-
// ethers v5 calls send(method, params)
|
|
319
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
320
293
|
async _sendInstrumented(payload) {
|
|
321
294
|
const startedAt = Date.now();
|
|
322
295
|
const payloads = Array.isArray(payload) ? payload : [payload];
|
|
323
|
-
this.stats.bumpInFlightPerProvider(this.providerId);
|
|
324
|
-
this.stats.bumpProviderTotal(this.providerId);
|
|
325
296
|
for (const p of payloads) {
|
|
326
|
-
this.stats.bumpPerMethod(this.providerId, p.method);
|
|
327
297
|
this.options.onEvent?.({
|
|
328
298
|
type: "request",
|
|
329
299
|
chainId: this.chainId,
|
|
@@ -346,36 +316,13 @@ var InstrumentedJsonRpcProvider = class extends import_ethers.JsonRpcProvider {
|
|
|
346
316
|
ms: endedAt - startedAt
|
|
347
317
|
});
|
|
348
318
|
}
|
|
349
|
-
this.lastCooldownMs = 0;
|
|
350
319
|
return res;
|
|
351
320
|
} catch (e) {
|
|
352
321
|
const endedAt = Date.now();
|
|
353
322
|
const rl = isRateLimitError(e);
|
|
354
|
-
if (rl) {
|
|
355
|
-
this.stats.bumpRateLimitedPerProvider(this.providerId);
|
|
356
|
-
const cooldownMs = 1e4;
|
|
357
|
-
const raMs = getRetryAfterMs(e) ?? cooldownMs;
|
|
358
|
-
this.stats.setCooldown(this.providerId, raMs);
|
|
359
|
-
}
|
|
360
323
|
const isTimeout = isTimeoutError(e);
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const n = this.stats.snapshot().perProviderTotal[this.providerId] || 0;
|
|
364
|
-
const ratio = this.stats.timeoutRatio(this.providerId);
|
|
365
|
-
if (n >= 50 && ratio >= 0.2) {
|
|
366
|
-
const cooldownMs = ratio >= 0.5 ? 6e5 : 6e4;
|
|
367
|
-
const raMs = getRetryAfterMs(e) ?? cooldownMs;
|
|
368
|
-
this.lastCooldownMs = raMs;
|
|
369
|
-
this.stats.setCooldown(this.providerId, raMs + Math.floor(Math.random() * 1e3));
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
const isError = isServerError(e);
|
|
373
|
-
if (isError && !rl && !isTimeout) {
|
|
374
|
-
this.stats.bumpServerErrorPerProvider(this.providerId);
|
|
375
|
-
const cooldownMs = (this.lastCooldownMs * 2 || 1e4) + Math.floor(Math.random() * 1e3);
|
|
376
|
-
this.lastCooldownMs = cooldownMs;
|
|
377
|
-
this.stats.setCooldown(this.providerId, cooldownMs);
|
|
378
|
-
}
|
|
324
|
+
const ne = isNetworkError(e);
|
|
325
|
+
const retryAfterMs = getRetryAfterMs(e) ?? void 0;
|
|
379
326
|
for (const p of payloads) {
|
|
380
327
|
this.options.onEvent?.({
|
|
381
328
|
type: "error",
|
|
@@ -387,37 +334,174 @@ var InstrumentedJsonRpcProvider = class extends import_ethers.JsonRpcProvider {
|
|
|
387
334
|
ms: endedAt - startedAt,
|
|
388
335
|
isRateLimit: rl,
|
|
389
336
|
isTimeout,
|
|
337
|
+
isNetworkError: ne,
|
|
390
338
|
status: getHttpStatus(e),
|
|
339
|
+
retryAfterMs,
|
|
391
340
|
code: e?.code,
|
|
392
341
|
message: String(e?.message || e),
|
|
393
342
|
errorKind: "transport"
|
|
394
343
|
});
|
|
395
344
|
}
|
|
396
345
|
throw e;
|
|
397
|
-
} finally {
|
|
398
|
-
this.stats.decreaseInFlightPerProvider(this.providerId);
|
|
399
346
|
}
|
|
400
347
|
}
|
|
401
348
|
};
|
|
402
349
|
|
|
403
350
|
// src/Router.ts
|
|
351
|
+
var EWMA_ALPHA = 0.2;
|
|
404
352
|
var Router = class {
|
|
405
|
-
constructor(
|
|
406
|
-
this.
|
|
407
|
-
|
|
353
|
+
constructor(inputs, availability) {
|
|
354
|
+
this.availability = availability;
|
|
355
|
+
const byPriority = /* @__PURE__ */ new Map();
|
|
356
|
+
for (const { endpoint, priority } of inputs) {
|
|
357
|
+
if (!byPriority.has(priority)) byPriority.set(priority, []);
|
|
358
|
+
byPriority.get(priority).push({ endpoint });
|
|
359
|
+
}
|
|
360
|
+
this._groups = [...byPriority.entries()].sort(([a], [b]) => b - a).map(([, entries]) => ({ entries, rr: 0 }));
|
|
361
|
+
this._size = inputs.length;
|
|
408
362
|
}
|
|
409
|
-
|
|
363
|
+
_groups;
|
|
364
|
+
_size;
|
|
365
|
+
_ewma = /* @__PURE__ */ new Map();
|
|
410
366
|
size() {
|
|
411
|
-
return this.
|
|
367
|
+
return this._size;
|
|
368
|
+
}
|
|
369
|
+
totalSlots() {
|
|
370
|
+
return this._size;
|
|
371
|
+
}
|
|
372
|
+
recordLatency(providerId, ms) {
|
|
373
|
+
const prev = this._ewma.get(providerId);
|
|
374
|
+
this._ewma.set(providerId, prev === void 0 ? ms : EWMA_ALPHA * ms + (1 - EWMA_ALPHA) * prev);
|
|
375
|
+
}
|
|
376
|
+
ewmaSnapshot() {
|
|
377
|
+
return Object.fromEntries(this._ewma);
|
|
412
378
|
}
|
|
413
379
|
pick() {
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
380
|
+
for (const group of this._groups) {
|
|
381
|
+
const ep = this._pickFromGroup(group);
|
|
382
|
+
if (ep !== null) return ep;
|
|
383
|
+
}
|
|
384
|
+
const g = this._groups[0];
|
|
385
|
+
const entry = g.entries[g.rr % g.entries.length];
|
|
386
|
+
g.rr++;
|
|
387
|
+
return entry.endpoint;
|
|
388
|
+
}
|
|
389
|
+
_pickFromGroup(group) {
|
|
390
|
+
const available = group.entries.filter(
|
|
391
|
+
(e) => !this.availability.isInCooldown(e.endpoint.providerId) && e.endpoint.provider.isAvailable(1)
|
|
392
|
+
);
|
|
393
|
+
if (available.length === 0) return null;
|
|
394
|
+
if (available.length === 1) return available[0].endpoint;
|
|
395
|
+
const i = Math.floor(Math.random() * available.length);
|
|
396
|
+
let j = Math.floor(Math.random() * (available.length - 1));
|
|
397
|
+
if (j >= i) j++;
|
|
398
|
+
const a = available[i].endpoint;
|
|
399
|
+
const b = available[j].endpoint;
|
|
400
|
+
const ewmaA = this._ewma.get(a.providerId) ?? 0;
|
|
401
|
+
const ewmaB = this._ewma.get(b.providerId) ?? 0;
|
|
402
|
+
return ewmaA <= ewmaB ? a : b;
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
// src/CooldownManager.ts
|
|
407
|
+
var CooldownManager = class {
|
|
408
|
+
_cooldownUntil = {};
|
|
409
|
+
_lastCooldownMs = {};
|
|
410
|
+
_perProviderTotal = {};
|
|
411
|
+
_perProviderTimeout = {};
|
|
412
|
+
_circuitState = {};
|
|
413
|
+
_probeInFlight = /* @__PURE__ */ new Set();
|
|
414
|
+
// atomically claims the probe slot when entering half-open
|
|
415
|
+
isInCooldown(id) {
|
|
416
|
+
const state = this._circuitState[id];
|
|
417
|
+
if (!state) return false;
|
|
418
|
+
if (state === "half-open") {
|
|
419
|
+
if (!this._probeInFlight.has(id)) {
|
|
420
|
+
this._probeInFlight.add(id);
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
const until = this._cooldownUntil[id];
|
|
426
|
+
if (until === void 0 || until <= Date.now()) {
|
|
427
|
+
delete this._cooldownUntil[id];
|
|
428
|
+
this._circuitState[id] = "half-open";
|
|
429
|
+
this._probeInFlight.add(id);
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
_openCircuit(id, ms) {
|
|
435
|
+
this._cooldownUntil[id] = Date.now() + ms;
|
|
436
|
+
this._circuitState[id] = "open";
|
|
437
|
+
this._probeInFlight.delete(id);
|
|
438
|
+
}
|
|
439
|
+
_openWithBackoff(id) {
|
|
440
|
+
const prev = this._lastCooldownMs[id] || 0;
|
|
441
|
+
const ms = (prev ? prev * 2 : 1e4) + Math.floor(Math.random() * 1e3);
|
|
442
|
+
this._lastCooldownMs[id] = ms;
|
|
443
|
+
this._openCircuit(id, ms);
|
|
444
|
+
}
|
|
445
|
+
cooldownSnapshot() {
|
|
446
|
+
return { ...this._cooldownUntil };
|
|
447
|
+
}
|
|
448
|
+
circuitStateSnapshot() {
|
|
449
|
+
const result = {};
|
|
450
|
+
for (const [id, state] of Object.entries(this._circuitState)) {
|
|
451
|
+
result[id] = state;
|
|
452
|
+
}
|
|
453
|
+
return result;
|
|
454
|
+
}
|
|
455
|
+
removeProvider(id) {
|
|
456
|
+
delete this._cooldownUntil[id];
|
|
457
|
+
delete this._lastCooldownMs[id];
|
|
458
|
+
delete this._perProviderTotal[id];
|
|
459
|
+
delete this._perProviderTimeout[id];
|
|
460
|
+
delete this._circuitState[id];
|
|
461
|
+
this._probeInFlight.delete(id);
|
|
462
|
+
}
|
|
463
|
+
onEvent(e) {
|
|
464
|
+
const id = e.providerId;
|
|
465
|
+
if (e.type === "request") {
|
|
466
|
+
this._perProviderTotal[id] = (this._perProviderTotal[id] || 0) + 1;
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (e.type === "response") {
|
|
470
|
+
if (this._circuitState[id] === "half-open") {
|
|
471
|
+
delete this._circuitState[id];
|
|
472
|
+
this._probeInFlight.delete(id);
|
|
473
|
+
}
|
|
474
|
+
this._lastCooldownMs[id] = 0;
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (this._circuitState[id] === "half-open") {
|
|
478
|
+
if (e.isRateLimit) {
|
|
479
|
+
this._openCircuit(id, e.retryAfterMs ?? 1e4);
|
|
480
|
+
} else {
|
|
481
|
+
this._openWithBackoff(id);
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (e.isRateLimit) {
|
|
486
|
+
this._openCircuit(id, e.retryAfterMs ?? 1e4);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (e.isTimeout) {
|
|
490
|
+
this._perProviderTimeout[id] = (this._perProviderTimeout[id] || 0) + 1;
|
|
491
|
+
const n = this._perProviderTotal[id] || 0;
|
|
492
|
+
const t = this._perProviderTimeout[id];
|
|
493
|
+
const ratio = n ? t / n : 0;
|
|
494
|
+
if (n >= 50 && ratio >= 0.2) {
|
|
495
|
+
const baseCooldown = ratio >= 0.5 ? 6e5 : 6e4;
|
|
496
|
+
const ms = (e.retryAfterMs ?? baseCooldown) + Math.floor(Math.random() * 1e3);
|
|
497
|
+
this._lastCooldownMs[id] = ms;
|
|
498
|
+
this._openCircuit(id, ms);
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (e.status !== void 0 && e.status >= 500 || e.isNetworkError) {
|
|
503
|
+
this._openWithBackoff(id);
|
|
419
504
|
}
|
|
420
|
-
return this.endpoints[(this.rr++ % n + n) % n];
|
|
421
505
|
}
|
|
422
506
|
};
|
|
423
507
|
|
|
@@ -425,35 +509,55 @@ var Router = class {
|
|
|
425
509
|
var RPCPoolProvider = class extends import_ethers2.JsonRpcProvider {
|
|
426
510
|
router;
|
|
427
511
|
params;
|
|
428
|
-
|
|
512
|
+
_stats;
|
|
513
|
+
_cooldown;
|
|
514
|
+
_endpoints;
|
|
515
|
+
_probeInterval;
|
|
429
516
|
constructor(params) {
|
|
430
517
|
const network = import_ethers2.Network.from(params.network);
|
|
431
518
|
super("http://localhost", network, { staticNetwork: network });
|
|
432
519
|
this.params = params;
|
|
433
|
-
this.
|
|
434
|
-
|
|
520
|
+
this._stats = new Stats();
|
|
521
|
+
this._cooldown = new CooldownManager();
|
|
522
|
+
const endpoints = [];
|
|
523
|
+
const routerInputs = this.params.rpc.map((options, i) => {
|
|
524
|
+
const { priority, network: _n, ...providerOptions } = options;
|
|
435
525
|
const url = typeof options.url === "string" ? options.url : options.url.url;
|
|
436
526
|
const providerId = `rpc#${i + 1}-chainId:${this.params.network}-${url}`;
|
|
437
527
|
const provider = new InstrumentedJsonRpcProvider(options.url, this.params.network, {
|
|
438
528
|
providerId,
|
|
439
|
-
stats: this.stats,
|
|
440
529
|
...this.params.defaultRpcOptions,
|
|
441
|
-
...
|
|
442
|
-
onEvent: this.
|
|
530
|
+
...providerOptions,
|
|
531
|
+
onEvent: (e) => this._handleTransportEvent(e)
|
|
443
532
|
});
|
|
444
|
-
|
|
533
|
+
endpoints.push({ id: providerId, provider });
|
|
534
|
+
const endpoint = { providerId, url, provider };
|
|
535
|
+
return { endpoint, priority: priority ?? 0 };
|
|
445
536
|
});
|
|
446
|
-
this.
|
|
537
|
+
this._endpoints = endpoints;
|
|
538
|
+
this.router = new Router(routerInputs, this._cooldown);
|
|
539
|
+
if (params.healthProbe !== void 0) {
|
|
540
|
+
const intervalMs = params.healthProbe.intervalMs ?? 15e3;
|
|
541
|
+
this._probeInterval = setInterval(() => this._runHealthProbe(), intervalMs);
|
|
542
|
+
this._probeInterval.unref?.();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
destroy() {
|
|
546
|
+
if (this._probeInterval !== void 0) {
|
|
547
|
+
clearInterval(this._probeInterval);
|
|
548
|
+
this._probeInterval = void 0;
|
|
549
|
+
}
|
|
447
550
|
}
|
|
448
551
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
449
552
|
async send(method, params) {
|
|
553
|
+
if (this.router.size() === 0) throw new Error("No RPC available");
|
|
450
554
|
const tried = /* @__PURE__ */ new Set();
|
|
451
555
|
const maxAttempts = this.params.retry.attempts;
|
|
452
556
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
453
557
|
let endpoint = this.router.pick();
|
|
454
558
|
if (tried.size < this.router.size()) {
|
|
455
559
|
let retries = 0;
|
|
456
|
-
while (tried.has(endpoint.providerId) && retries < this.router.
|
|
560
|
+
while (tried.has(endpoint.providerId) && retries < this.router.totalSlots()) {
|
|
457
561
|
endpoint = this.router.pick();
|
|
458
562
|
retries++;
|
|
459
563
|
}
|
|
@@ -464,23 +568,60 @@ var RPCPoolProvider = class extends import_ethers2.JsonRpcProvider {
|
|
|
464
568
|
return await endpoint.provider.send(method, params);
|
|
465
569
|
} catch (e) {
|
|
466
570
|
if (isRpcLogicalError(e)) {
|
|
467
|
-
this.
|
|
571
|
+
this._emitRpcLogicalError(endpoint, method, startedAt, e);
|
|
468
572
|
}
|
|
469
573
|
if (!shouldFailover(e)) throw e;
|
|
470
574
|
if (attempt === maxAttempts - 1) throw e;
|
|
471
|
-
await this.
|
|
575
|
+
await this._sleepWithBackoff(attempt);
|
|
472
576
|
}
|
|
473
577
|
}
|
|
474
578
|
throw new Error("No RPC available");
|
|
475
579
|
}
|
|
476
|
-
|
|
580
|
+
getSnapshot() {
|
|
581
|
+
return {
|
|
582
|
+
...this._stats.snapshot(),
|
|
583
|
+
providerCooldownUntil: this._cooldown.cooldownSnapshot(),
|
|
584
|
+
providerCircuitState: this._cooldown.circuitStateSnapshot(),
|
|
585
|
+
perProviderLatencyEwma: this.router.ewmaSnapshot()
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
// Returns a provider pinned to a single RPC node selected right now.
|
|
589
|
+
// Use when consecutive calls must see consistent chain state — e.g. read
|
|
590
|
+
// eth_getBalance on block N, then eth_call on the same block N. Without
|
|
591
|
+
// pinning, each call may route to a different node that lags behind.
|
|
592
|
+
pinnedProvider() {
|
|
593
|
+
return this.router.pick().provider;
|
|
594
|
+
}
|
|
595
|
+
_runHealthProbe() {
|
|
596
|
+
const states = this._cooldown.circuitStateSnapshot();
|
|
597
|
+
const cooldowns = this._cooldown.cooldownSnapshot();
|
|
598
|
+
const now = Date.now();
|
|
599
|
+
for (const { id, provider } of this._endpoints) {
|
|
600
|
+
if (states[id] !== "open") continue;
|
|
601
|
+
const cooldownUntil = cooldowns[id];
|
|
602
|
+
if (cooldownUntil !== void 0 && cooldownUntil > now) continue;
|
|
603
|
+
if (!this._cooldown.isInCooldown(id)) {
|
|
604
|
+
provider.send("eth_blockNumber", []).catch(() => {
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
_handleTransportEvent(e) {
|
|
610
|
+
this._stats.onEvent(e);
|
|
611
|
+
this._cooldown.onEvent(e);
|
|
612
|
+
if (e.type === "response" || e.type === "error") {
|
|
613
|
+
this.router.recordLatency(e.providerId, e.ms);
|
|
614
|
+
}
|
|
615
|
+
this.params.hooks?.onEvent?.(e);
|
|
616
|
+
}
|
|
617
|
+
async _sleepWithBackoff(attempt) {
|
|
477
618
|
const baseDelay = Math.min(1e3 * 2 ** attempt, 5e3);
|
|
478
619
|
const jitter = Math.random() * baseDelay;
|
|
479
620
|
await new Promise((resolve) => setTimeout(resolve, jitter));
|
|
480
621
|
}
|
|
481
|
-
|
|
622
|
+
_emitRpcLogicalError(ep, method, startedAt, error) {
|
|
482
623
|
const endedAt = Date.now();
|
|
483
|
-
this.
|
|
624
|
+
this._stats.bumpRpcError(ep.providerId, method);
|
|
484
625
|
this.params.hooks?.onEvent?.({
|
|
485
626
|
type: "error",
|
|
486
627
|
chainId: BigInt(import_ethers2.Network.from(this.params.network).chainId),
|
|
@@ -491,15 +632,13 @@ var RPCPoolProvider = class extends import_ethers2.JsonRpcProvider {
|
|
|
491
632
|
ms: endedAt - startedAt,
|
|
492
633
|
isRateLimit: false,
|
|
493
634
|
isTimeout: false,
|
|
635
|
+
isNetworkError: false,
|
|
494
636
|
status: void 0,
|
|
495
637
|
code: error?.code,
|
|
496
638
|
message: String(error?.message || error),
|
|
497
639
|
errorKind: "rpc"
|
|
498
640
|
});
|
|
499
641
|
}
|
|
500
|
-
getStats() {
|
|
501
|
-
return this.stats;
|
|
502
|
-
}
|
|
503
642
|
};
|
|
504
643
|
// Annotate the CommonJS export names for ESM import in node:
|
|
505
644
|
0 && (module.exports = {
|