ethers-rpc-pool 1.1.4 → 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/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
[](https://www.npmjs.com/package/ethers-rpc-pool)
|
|
2
2
|

|
|
3
|
+
[](https://github.com/ahiipsa/ethers-rpc-pool/actions/workflows/ci.yml)
|
|
4
|
+
[](https://codecov.io/gh/ahiipsa/ethers-rpc-pool)
|
|
3
5
|
|
|
4
6
|
# ethers-rpc-pool
|
|
5
7
|
|
|
@@ -18,23 +20,30 @@ Designed for production backends and dApps that need:
|
|
|
18
20
|
## Table of Contents
|
|
19
21
|
|
|
20
22
|
- [Why ethers-rpc-pool](#why-ethers-rpc-pool)
|
|
23
|
+
- [vs FallbackProvider](#vs-fallbackprovider)
|
|
21
24
|
- [Features](#features)
|
|
22
25
|
- [Requirements](#requirements)
|
|
23
26
|
- [Installation](#installation)
|
|
24
27
|
- [Quick Start](#quick-start)
|
|
25
28
|
- [Configuration](#configuration)
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
- -
|
|
29
|
+
- [Interfaces](#interfaces)
|
|
30
|
+
- [RPCPoolProvider Options](#rpcpoolprovider-options)
|
|
31
|
+
- [Per-Endpoint Options](#per-endpoint-options)
|
|
29
32
|
- [How It Works](#how-it-works)
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
33
|
+
- [Routing](#1-routing)
|
|
34
|
+
- [Concurrency Control](#2-concurrency-control)
|
|
35
|
+
- [Rate Limiting](#3-rate-limiting)
|
|
36
|
+
- [Retry Strategy](#4-retry-strategy)
|
|
37
|
+
- [Circuit Breaker](#5-circuit-breaker)
|
|
38
|
+
- [Pinned Provider](#6-pinned-provider)
|
|
34
39
|
- [Instrumentation & Metrics](#instrumentation--metrics)
|
|
40
|
+
- [RpcEvent](#rpcevent)
|
|
41
|
+
- [Stats Snapshot](#stats-snapshot)
|
|
42
|
+
- [Prometheus Example](#prometheus-example)
|
|
43
|
+
- [OpenTelemetry Example](#opentelemetry-example)
|
|
35
44
|
- [Production Considerations](#production-considerations)
|
|
36
|
-
-
|
|
37
|
-
-
|
|
45
|
+
- [Recommended Settings](#recommended-settings)
|
|
46
|
+
- [Known Limitations](#known-limitations)
|
|
38
47
|
- [When To Use](#when-to-use)
|
|
39
48
|
- [Example Architecture](#example-architecture)
|
|
40
49
|
- [Roadmap](#roadmap)
|
|
@@ -43,31 +52,60 @@ Designed for production backends and dApps that need:
|
|
|
43
52
|
|
|
44
53
|
## Why ethers-rpc-pool?
|
|
45
54
|
|
|
46
|
-
Most production apps rely on a single RPC provider. This creates
|
|
55
|
+
Most production apps rely on a single RPC provider. This creates a single point of failure, hard concurrency limits, and cascading retry storms during traffic spikes.
|
|
47
56
|
|
|
48
|
-
-
|
|
49
|
-
- Hard concurrency limits (RPS / in-flight)
|
|
50
|
-
- Increased timeout risk during traffic spikes
|
|
51
|
-
- Cascading retry storms
|
|
57
|
+
`ethers-rpc-pool` distributes traffic across multiple endpoints, applies per-endpoint rate limiting and concurrency control, and automatically fails over to healthy providers — all behind the familiar `JsonRpcProvider` API.
|
|
52
58
|
|
|
53
|
-
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## vs FallbackProvider
|
|
62
|
+
|
|
63
|
+
ethers.js ships with a built-in `FallbackProvider`. Here is how the two compare:
|
|
64
|
+
|
|
65
|
+
| Capability | ethers-rpc-pool | FallbackProvider |
|
|
66
|
+
| -------------------------------------------------------- | :-------------: | :---------------------------: |
|
|
67
|
+
| Per-endpoint token-bucket RPS limiting | ✅ | ❌ |
|
|
68
|
+
| Per-endpoint concurrency control (`inFlight`) | ✅ | ❌ |
|
|
69
|
+
| Respects `Retry-After` header on 429 | ✅ | ❌ |
|
|
70
|
+
| Graduated cooldown (rate limit / timeout / 5xx) | ✅ | ❌ |
|
|
71
|
+
| Circuit breaker with half-open probe | ✅ | ❌ |
|
|
72
|
+
| Retries on transport errors only (not on logical errors) | ✅ | ❌ |
|
|
73
|
+
| Structured observability events (`RpcEvent`) | ✅ | ❌ |
|
|
74
|
+
| Per-provider stats snapshot | ✅ | ❌ |
|
|
75
|
+
| EWMA latency-based routing (P2C) | ✅ | ❌ |
|
|
76
|
+
| Sequential failover (one endpoint at a time) | ✅ | ❌ (fires all simultaneously) |
|
|
77
|
+
| ethers.js v6 compatible | ✅ | ✅ |
|
|
78
|
+
| Drop-in library (no extra infra) | ✅ | ✅ |
|
|
79
|
+
| Quorum / consensus across backends | ❌ | ✅ |
|
|
80
|
+
|
|
81
|
+
**When FallbackProvider is the right choice:** you need result consensus across multiple nodes (e.g. reading from multiple archive nodes and comparing answers).
|
|
82
|
+
|
|
83
|
+
**When ethers-rpc-pool is the right choice:** you need high-throughput, rate-limit-aware, observable RPC access from a backend service — and you don't care which node answers, only that _someone_ does quickly and reliably.
|
|
84
|
+
|
|
85
|
+
### Known FallbackProvider issues in production
|
|
54
86
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
-
|
|
58
|
-
-
|
|
59
|
-
-
|
|
87
|
+
Several recurring problems are documented in the ethers.js issue tracker:
|
|
88
|
+
|
|
89
|
+
- **Hangs on slow RPCs** — if one backend stalls, the entire provider can stall even when others are healthy ([#2030](https://github.com/ethers-io/ethers.js/issues/2030))
|
|
90
|
+
- **Fires all backends simultaneously** — even with `quorum: 1`, every request is sent to all backends, wasting RPC quota ([#3118](https://github.com/ethers-io/ethers.js/issues/3118))
|
|
91
|
+
- **No rate-limit awareness** — no concept of per-endpoint RPS limits or `Retry-After` headers
|
|
92
|
+
- **Broken error handling for non-ETH errors** — a 401 Unauthorized can be reported as contract reversion ([discussion #3500](https://github.com/ethers-io/ethers.js/discussions/3500))
|
|
93
|
+
|
|
94
|
+
`ethers-rpc-pool` addresses all of these.
|
|
60
95
|
|
|
61
96
|
---
|
|
62
97
|
|
|
63
98
|
## Features
|
|
64
99
|
|
|
65
|
-
- 🔀 Load balancing across multiple RPC endpoints
|
|
100
|
+
- 🔀 Load balancing with EWMA latency-based routing (P2C) across multiple RPC endpoints
|
|
66
101
|
- 🚦 Per-endpoint concurrency limit (`inFlight`)
|
|
67
102
|
- 🔁 Retry with exponential backoff and jitter
|
|
68
103
|
- ⚡ Automatic failover on retryable errors
|
|
104
|
+
- 🔒 Circuit breaker with half-open probe per endpoint
|
|
105
|
+
- 📍 Pinned provider for consistent chain-state reads across multiple calls
|
|
69
106
|
- 📊 Built-in request statistics
|
|
70
107
|
- 🧩 Drop-in replacement for `JsonRpcProvider`
|
|
108
|
+
- ✅ 100 % test coverage (lines, statements, functions)
|
|
71
109
|
|
|
72
110
|
---
|
|
73
111
|
|
|
@@ -92,19 +130,21 @@ npm install ethers-rpc-pool
|
|
|
92
130
|
import { RPCPoolProvider } from 'ethers-rpc-pool';
|
|
93
131
|
|
|
94
132
|
const poolProvider = new RPCPoolProvider({
|
|
95
|
-
|
|
133
|
+
network: 1,
|
|
96
134
|
rpc: [
|
|
97
135
|
{ url: 'https://eth.drpc.org' },
|
|
98
136
|
{ url: 'https://eth1.lava.build' },
|
|
99
137
|
{ url: 'https://rpc.mevblocker.io' },
|
|
100
138
|
{ url: 'https://eth.blockrazor.xyz' },
|
|
101
|
-
|
|
139
|
+
// Override defaults for a specific endpoint:
|
|
140
|
+
{ url: 'https://public-eth.nownodes.io', rps: 5, inFlight: 2 },
|
|
102
141
|
],
|
|
142
|
+
// Applied to every endpoint unless overridden per-item above:
|
|
103
143
|
defaultRpcOptions: { inFlight: 1, timeout: 3000, rps: 2, rpsBurst: 5 },
|
|
104
144
|
retry: { attempts: 3 },
|
|
105
145
|
});
|
|
106
146
|
|
|
107
|
-
//
|
|
147
|
+
// Drop-in replacement for JsonRpcProvider:
|
|
108
148
|
const blockNumber = await poolProvider.getBlockNumber();
|
|
109
149
|
const balance = await poolProvider.getBalance('0x...');
|
|
110
150
|
```
|
|
@@ -116,18 +156,38 @@ const balance = await poolProvider.getBalance('0x...');
|
|
|
116
156
|
### Interfaces
|
|
117
157
|
|
|
118
158
|
```ts
|
|
119
|
-
interface
|
|
159
|
+
interface RPCPoolProviderParams {
|
|
160
|
+
network: Networkish; // chain ID number, name string, or ethers Network object
|
|
161
|
+
rpc: RpcEndpointOptions[]; // list of RPC endpoints
|
|
162
|
+
defaultRpcOptions: {
|
|
163
|
+
inFlight: number; // required; other fields are optional
|
|
164
|
+
timeout?: number;
|
|
165
|
+
rps?: number;
|
|
166
|
+
rpsBurst?: number;
|
|
167
|
+
};
|
|
168
|
+
retry: {
|
|
169
|
+
attempts: number; // max number of unique endpoints to try
|
|
170
|
+
};
|
|
171
|
+
hooks?: {
|
|
172
|
+
onEvent(e: RpcEvent): void;
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// Options for a single RPC endpoint.
|
|
179
|
+
// Per-endpoint values override defaultRpcOptions.
|
|
180
|
+
interface RpcEndpointOptions {
|
|
181
|
+
url: string | FetchRequest; // endpoint URL
|
|
182
|
+
priority?: number; // routing tier (default 0, higher = tried first)
|
|
183
|
+
|
|
184
|
+
// ethers-rpc-pool options (all optional; fall back to defaultRpcOptions):
|
|
120
185
|
inFlight?: number;
|
|
121
186
|
timeout?: number;
|
|
122
187
|
rps?: number;
|
|
123
188
|
rpsBurst?: number;
|
|
124
189
|
|
|
125
|
-
// Optional
|
|
126
|
-
stats?: Stats;
|
|
127
|
-
onEvent?: (e: RpcEvent) => void;
|
|
128
|
-
providerId: string;
|
|
129
|
-
|
|
130
|
-
// Optional JsonRpcProvider options for compatibility
|
|
190
|
+
// Optional ethers.js JsonRpcApiProviderOptions:
|
|
131
191
|
// https://docs.ethers.org/v6/api/providers/jsonrpc/#JsonRpcApiProviderOptions
|
|
132
192
|
batchStallTime?: number;
|
|
133
193
|
batchMaxSize?: number;
|
|
@@ -139,39 +199,27 @@ interface RPCParameters {
|
|
|
139
199
|
}
|
|
140
200
|
```
|
|
141
201
|
|
|
142
|
-
```ts
|
|
143
|
-
interface PoolProviderParameters {
|
|
144
|
-
network: number;
|
|
145
|
-
rpc: RpcProviderOptions[];
|
|
146
|
-
defaultRpcOptions?: RpcProviderOptions;
|
|
147
|
-
retry: {
|
|
148
|
-
attempts: number;
|
|
149
|
-
};
|
|
150
|
-
hooks?: {
|
|
151
|
-
onEvent(e: RpcEvent): void;
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
```
|
|
155
|
-
|
|
156
202
|
### RPCPoolProvider Options
|
|
157
203
|
|
|
158
|
-
| Option | Description
|
|
159
|
-
| ------------------- |
|
|
160
|
-
| `network` |
|
|
161
|
-
| `rpc` | List of RPC endpoints
|
|
162
|
-
| `retry.attempts` | Maximum number of unique endpoints to try |
|
|
163
|
-
| `defaultRpcOptions` | Default options
|
|
164
|
-
| `hooks.onEvent` | Optional
|
|
165
|
-
|
|
166
|
-
###
|
|
167
|
-
|
|
168
|
-
| Option | Description
|
|
169
|
-
| ---------- |
|
|
170
|
-
| `
|
|
171
|
-
| `
|
|
172
|
-
| `
|
|
173
|
-
| `
|
|
174
|
-
|
|
|
204
|
+
| Option | Description |
|
|
205
|
+
| ------------------- | ----------------------------------------------------------------------------------------- |
|
|
206
|
+
| `network` | Chain identifier (`Networkish`: chain ID number, name string, or ethers `Network` object) |
|
|
207
|
+
| `rpc` | List of RPC endpoints (see `RpcEndpointOptions` above) |
|
|
208
|
+
| `retry.attempts` | Maximum number of unique endpoints to try before giving up |
|
|
209
|
+
| `defaultRpcOptions` | Default options applied to every endpoint; per-endpoint values override these |
|
|
210
|
+
| `hooks.onEvent` | Optional callback fired on every request, response, and error (see `RpcEvent` below) |
|
|
211
|
+
|
|
212
|
+
### Per-Endpoint Options
|
|
213
|
+
|
|
214
|
+
| Option | Default | Description |
|
|
215
|
+
| ---------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
216
|
+
| `url` | — | RPC endpoint URL (required) |
|
|
217
|
+
| `priority` | `0` | Routing tier. Higher value = tried first. When all endpoints in a tier are unavailable, routing falls through to the next tier. |
|
|
218
|
+
| `inFlight` | `1` | Max concurrent in-flight requests |
|
|
219
|
+
| `timeout` | `10000` | HTTP timeout in ms |
|
|
220
|
+
| `rps` | `10` | Sustained request rate (requests/sec). Enforced by a token bucket. |
|
|
221
|
+
| `rpsBurst` | `= rps` | Burst capacity. Allows short spikes above `rps` by consuming tokens accumulated during idle time. |
|
|
222
|
+
| `...` | — | Any [ethers.JsonRpcApiProviderOptions](https://docs.ethers.org/v6/api/providers/jsonrpc/#JsonRpcApiProviderOptions) are also accepted. |
|
|
175
223
|
|
|
176
224
|
---
|
|
177
225
|
|
|
@@ -179,7 +227,17 @@ interface PoolProviderParameters {
|
|
|
179
227
|
|
|
180
228
|
### 1. Routing
|
|
181
229
|
|
|
182
|
-
|
|
230
|
+
Endpoints are grouped by `priority` and tried high→low. Within each priority tier, the router uses **EWMA latency + Power of Two Choices (P2C)**: two candidates are drawn at random from the available pool and the one with the lower exponentially-weighted moving average latency (α = 0.2) is picked. Unsampled endpoints start at EWMA = 0 and are naturally explored before measured ones. Both successful responses and errors contribute to the EWMA, so a slow or failing endpoint is progressively deprioritised even before its circuit opens.
|
|
231
|
+
|
|
232
|
+
If every endpoint in a tier is unavailable, routing falls through to the next tier. If every endpoint across all tiers is unavailable, the pool falls back to round-robin over the highest-priority group — it never deadlocks.
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
rpc: [
|
|
236
|
+
{ url: 'https://alchemy.com/...', priority: 1 }, // tried first
|
|
237
|
+
{ url: 'https://eth.drpc.org', priority: 0 }, // fallback tier
|
|
238
|
+
{ url: 'https://eth1.lava.build', priority: 0 }, // shares fallback tier; EWMA decides the split
|
|
239
|
+
];
|
|
240
|
+
```
|
|
183
241
|
|
|
184
242
|
### 2. Concurrency Control
|
|
185
243
|
|
|
@@ -239,38 +297,225 @@ Attempt 3 → random(0..2000ms)
|
|
|
239
297
|
...
|
|
240
298
|
```
|
|
241
299
|
|
|
242
|
-
Retries only
|
|
300
|
+
Retries happen only on failover-safe transport errors: **rate limit (429/402)**, **timeout (504, ETIMEDOUT)**, and **server errors (5xx)**. RPC logical errors (execution reverted, invalid params, method not supported) are not retried.
|
|
301
|
+
|
|
302
|
+
### 5. Circuit Breaker
|
|
303
|
+
|
|
304
|
+
Each endpoint has an independent three-state circuit breaker managed by `CooldownManager`:
|
|
305
|
+
|
|
306
|
+
```
|
|
307
|
+
closed ──(error threshold)──▶ open ──(cooldown expires)──▶ half-open
|
|
308
|
+
▲ │
|
|
309
|
+
└──────────────(probe success)────────────────────────────────┘
|
|
310
|
+
▲
|
|
311
|
+
└──────────────(probe failure)────────────────────────── open (escalated cooldown)
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
| State | Behaviour |
|
|
315
|
+
| ----------- | ------------------------------------------------------- |
|
|
316
|
+
| `closed` | Normal operation — all requests pass through |
|
|
317
|
+
| `open` | Endpoint is in cooldown — router skips it |
|
|
318
|
+
| `half-open` | Cooldown expired — one probe request is allowed through |
|
|
319
|
+
|
|
320
|
+
When the probe succeeds the circuit closes and traffic resumes normally. When the probe fails the circuit re-opens with an escalated cooldown (exponential backoff for 5xx/timeout; `Retry-After` for rate-limits).
|
|
321
|
+
|
|
322
|
+
The current circuit state for each endpoint is included in `getSnapshot()` under `providerCircuitState`.
|
|
323
|
+
|
|
324
|
+
### 6. Pinned Provider
|
|
325
|
+
|
|
326
|
+
Different RPC nodes may lag behind by different numbers of blocks. When requests from the same logical flow go to different endpoints, the client can observe inconsistent state — `eth_getBalance` returns data from block 100, but the next `eth_call` lands on a node at block 99. This is especially dangerous in DeFi: read state → build transaction → node doesn't "see" the previous block yet.
|
|
327
|
+
|
|
328
|
+
`pinnedProvider()` selects the best available endpoint at call time and returns its `InstrumentedJsonRpcProvider` directly. All subsequent calls through that provider go to the same node.
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
const pinned = pool.pinnedProvider();
|
|
332
|
+
|
|
333
|
+
// All three calls go to the same RPC node:
|
|
334
|
+
const balance = await pinned.getBalance('0x...');
|
|
335
|
+
const nonce = await pinned.getTransactionCount('0x...');
|
|
336
|
+
const code = await pinned.getCode('0x...');
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
The endpoint is selected using the same P2C/EWMA routing as `pool.send()`. Since the returned provider is a plain `InstrumentedJsonRpcProvider`, there is no automatic failover — if the pinned node goes down mid-session, call `pinnedProvider()` again to re-pin to a healthy endpoint.
|
|
243
340
|
|
|
244
341
|
---
|
|
245
342
|
|
|
246
343
|
## Instrumentation & Metrics
|
|
247
344
|
|
|
248
|
-
|
|
345
|
+
### RpcEvent
|
|
346
|
+
|
|
347
|
+
Every request, successful response, and error fires an `RpcEvent` through the optional `hooks.onEvent` callback:
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
type RpcEvent =
|
|
351
|
+
| {
|
|
352
|
+
type: 'request';
|
|
353
|
+
chainId: bigint;
|
|
354
|
+
providerId: string; // e.g. "rpc#1-chainId:1-https://eth.drpc.org"
|
|
355
|
+
method: string; // e.g. "eth_blockNumber"
|
|
356
|
+
startedAt: number; // Unix timestamp (ms)
|
|
357
|
+
}
|
|
358
|
+
| {
|
|
359
|
+
type: 'response';
|
|
360
|
+
chainId: bigint;
|
|
361
|
+
providerId: string;
|
|
362
|
+
method: string;
|
|
363
|
+
startedAt: number;
|
|
364
|
+
endedAt: number;
|
|
365
|
+
ms: number; // round-trip time in ms
|
|
366
|
+
}
|
|
367
|
+
| {
|
|
368
|
+
type: 'error';
|
|
369
|
+
chainId: bigint;
|
|
370
|
+
providerId: string;
|
|
371
|
+
method: string;
|
|
372
|
+
startedAt: number;
|
|
373
|
+
endedAt: number;
|
|
374
|
+
ms: number;
|
|
375
|
+
isRateLimit: boolean;
|
|
376
|
+
isTimeout: boolean;
|
|
377
|
+
status?: number; // HTTP status code if available
|
|
378
|
+
retryAfterMs?: number; // from Retry-After header
|
|
379
|
+
code?: string; // ethers error code
|
|
380
|
+
message: string;
|
|
381
|
+
errorKind?: 'transport' | 'rpc';
|
|
382
|
+
};
|
|
383
|
+
```
|
|
249
384
|
|
|
250
|
-
|
|
385
|
+
Use `hooks.onEvent` to feed events into Prometheus, OpenTelemetry, or custom logging:
|
|
386
|
+
|
|
387
|
+
```ts
|
|
251
388
|
const poolProvider = new RPCPoolProvider({
|
|
252
389
|
// ...
|
|
253
390
|
hooks: {
|
|
254
391
|
onEvent(event) {
|
|
255
|
-
|
|
392
|
+
if (event.type === 'error' && event.isRateLimit) {
|
|
393
|
+
rateLimitCounter.inc({ provider: event.providerId });
|
|
394
|
+
}
|
|
256
395
|
},
|
|
257
396
|
},
|
|
258
397
|
});
|
|
259
398
|
```
|
|
260
399
|
|
|
261
|
-
|
|
400
|
+
### Prometheus Example
|
|
401
|
+
|
|
402
|
+
Uses [`prom-client`](https://github.com/siimon/prom-client):
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
import { Counter, Histogram, Registry } from 'prom-client';
|
|
406
|
+
import { RPCPoolProvider } from 'ethers-rpc-pool';
|
|
262
407
|
|
|
263
|
-
|
|
264
|
-
- OpenTelemetry
|
|
265
|
-
- Custom logging pipelines
|
|
408
|
+
const registry = new Registry();
|
|
266
409
|
|
|
267
|
-
|
|
410
|
+
const rpcRequests = new Counter({
|
|
411
|
+
name: 'rpc_requests_total',
|
|
412
|
+
help: 'Total RPC requests sent',
|
|
413
|
+
labelNames: ['provider', 'method'],
|
|
414
|
+
registers: [registry],
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
const rpcDuration = new Histogram({
|
|
418
|
+
name: 'rpc_request_duration_ms',
|
|
419
|
+
help: 'RPC round-trip time in milliseconds',
|
|
420
|
+
labelNames: ['provider', 'method'],
|
|
421
|
+
buckets: [50, 100, 250, 500, 1000, 2500, 5000],
|
|
422
|
+
registers: [registry],
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const rpcErrors = new Counter({
|
|
426
|
+
name: 'rpc_errors_total',
|
|
427
|
+
help: 'Total RPC errors',
|
|
428
|
+
labelNames: ['provider', 'method', 'kind'],
|
|
429
|
+
registers: [registry],
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const pool = new RPCPoolProvider({
|
|
433
|
+
network: 1,
|
|
434
|
+
rpc: [{ url: 'https://eth.drpc.org' }, { url: 'https://eth1.lava.build' }],
|
|
435
|
+
defaultRpcOptions: { inFlight: 1, rps: 10 },
|
|
436
|
+
retry: { attempts: 3 },
|
|
437
|
+
hooks: {
|
|
438
|
+
onEvent(e) {
|
|
439
|
+
if (e.type === 'request') {
|
|
440
|
+
rpcRequests.inc({ provider: e.providerId, method: e.method });
|
|
441
|
+
} else if (e.type === 'response') {
|
|
442
|
+
rpcDuration.observe({ provider: e.providerId, method: e.method }, e.ms);
|
|
443
|
+
} else if (e.type === 'error') {
|
|
444
|
+
const kind = e.isRateLimit ? 'rate_limit' : e.isTimeout ? 'timeout' : 'server';
|
|
445
|
+
rpcErrors.inc({ provider: e.providerId, method: e.method, kind });
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### OpenTelemetry Example
|
|
453
|
+
|
|
454
|
+
Uses `@opentelemetry/api`:
|
|
455
|
+
|
|
456
|
+
```ts
|
|
457
|
+
import { metrics } from '@opentelemetry/api';
|
|
458
|
+
import { RPCPoolProvider } from 'ethers-rpc-pool';
|
|
459
|
+
|
|
460
|
+
const meter = metrics.getMeter('ethers-rpc-pool');
|
|
461
|
+
|
|
462
|
+
const rpcDuration = meter.createHistogram('rpc.request.duration', {
|
|
463
|
+
description: 'RPC round-trip time in milliseconds',
|
|
464
|
+
unit: 'ms',
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const rpcErrors = meter.createCounter('rpc.errors', {
|
|
468
|
+
description: 'Total RPC errors by kind',
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const pool = new RPCPoolProvider({
|
|
472
|
+
network: 1,
|
|
473
|
+
rpc: [{ url: 'https://eth.drpc.org' }, { url: 'https://eth1.lava.build' }],
|
|
474
|
+
defaultRpcOptions: { inFlight: 1, rps: 10 },
|
|
475
|
+
retry: { attempts: 3 },
|
|
476
|
+
hooks: {
|
|
477
|
+
onEvent(e) {
|
|
478
|
+
const attrs = { 'rpc.provider': e.providerId, 'rpc.method': e.method };
|
|
479
|
+
if (e.type === 'response') {
|
|
480
|
+
rpcDuration.record(e.ms, attrs);
|
|
481
|
+
} else if (e.type === 'error') {
|
|
482
|
+
const kind = e.isRateLimit ? 'rate_limit' : e.isTimeout ? 'timeout' : 'server';
|
|
483
|
+
rpcErrors.add(1, { ...attrs, 'rpc.error.kind': kind });
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
### Stats Snapshot
|
|
491
|
+
|
|
492
|
+
`getSnapshot()` returns a point-in-time copy of all counters:
|
|
268
493
|
|
|
269
494
|
```ts
|
|
270
|
-
const
|
|
271
|
-
console.log(stats.snapshot());
|
|
495
|
+
const snapshot = pool.getSnapshot();
|
|
272
496
|
```
|
|
273
497
|
|
|
498
|
+
| Field | Description |
|
|
499
|
+
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
500
|
+
| `total` | Total requests sent (counts each retry attempt) |
|
|
501
|
+
| `inFlight` | Currently in-flight requests across all endpoints |
|
|
502
|
+
| `perMethodTotal` | Request count per JSON-RPC method |
|
|
503
|
+
| `rateLimitedTotal` | Total 429/rate-limit errors |
|
|
504
|
+
| `perProviderRateLimited` | Rate-limit errors per endpoint |
|
|
505
|
+
| `timeoutTotal` | Total timeout errors |
|
|
506
|
+
| `perProviderTimeout` | Timeout errors per endpoint |
|
|
507
|
+
| `serverErrorTotal` | Total 5xx server errors |
|
|
508
|
+
| `perProviderTotal` | Total requests per endpoint |
|
|
509
|
+
| `perProviderInFlight` | Currently in-flight requests per endpoint |
|
|
510
|
+
| `perProviderError` | Transport errors (5xx, network) per endpoint |
|
|
511
|
+
| `rpcErrorTotal` | Total RPC logical errors (revert, invalid params, etc.) |
|
|
512
|
+
| `perProviderRpcError` | RPC logical errors per endpoint, broken down by method |
|
|
513
|
+
| `perMethodRpcError` | RPC logical errors per method |
|
|
514
|
+
| `perProviderMethod` | Request count per endpoint per method |
|
|
515
|
+
| `providerCooldownUntil` | Unix timestamp (ms) when each endpoint's cooldown ends |
|
|
516
|
+
| `providerCircuitState` | Circuit breaker state per endpoint (`'open'` or `'half-open'`; absent when `'closed'`) |
|
|
517
|
+
| `perProviderLatencyEwma` | EWMA latency in ms (α = 0.2) per endpoint, as used by the router for P2C decisions. Absent for endpoints that have not yet been sampled. |
|
|
518
|
+
|
|
274
519
|
### Example output:
|
|
275
520
|
|
|
276
521
|
```json
|
|
@@ -286,9 +531,15 @@ console.log(stats.snapshot());
|
|
|
286
531
|
},
|
|
287
532
|
"rateLimitedTotal": 0,
|
|
288
533
|
"timeoutTotal": 0,
|
|
534
|
+
"serverErrorTotal": 0,
|
|
535
|
+
"rpcErrorTotal": 0,
|
|
289
536
|
"perProviderRateLimited": {},
|
|
290
537
|
"perProviderTimeout": {},
|
|
538
|
+
"perProviderError": {},
|
|
539
|
+
"perProviderRpcError": {},
|
|
540
|
+
"perMethodRpcError": {},
|
|
291
541
|
"providerCooldownUntil": {},
|
|
542
|
+
"providerCircuitState": {},
|
|
292
543
|
"perProviderInFlight": {
|
|
293
544
|
"rpc#1-chainId:1-https://eth.drpc.org": 0,
|
|
294
545
|
"rpc#2-chainId:1-https://eth1.lava.build": 0,
|
|
@@ -302,18 +553,18 @@ console.log(stats.snapshot());
|
|
|
302
553
|
"rpc#3-chainId:1-https://rpc.mevblocker.io": 21,
|
|
303
554
|
"rpc#4-chainId:1-https://eth.blockrazor.xyz": 21,
|
|
304
555
|
"rpc#5-chainId:1-https://public-eth.nownodes.io": 21
|
|
556
|
+
},
|
|
557
|
+
"perProviderMethod": {
|
|
558
|
+
"rpc#1-chainId:1-https://eth.drpc.org": { "eth_blockNumber": 21 }
|
|
559
|
+
},
|
|
560
|
+
"perProviderLatencyEwma": {
|
|
561
|
+
"rpc#1-chainId:1-https://eth.drpc.org": 142.3,
|
|
562
|
+
"rpc#2-chainId:1-https://eth1.lava.build": 98.7,
|
|
563
|
+
"rpc#3-chainId:1-https://rpc.mevblocker.io": 201.5
|
|
305
564
|
}
|
|
306
565
|
}
|
|
307
566
|
```
|
|
308
567
|
|
|
309
|
-
Useful for:
|
|
310
|
-
|
|
311
|
-
- Request counters
|
|
312
|
-
- Per-method stats
|
|
313
|
-
- Per-provider metrics
|
|
314
|
-
- Timeout tracking
|
|
315
|
-
- Rate limit detection
|
|
316
|
-
|
|
317
568
|
---
|
|
318
569
|
|
|
319
570
|
## Production Considerations
|
|
@@ -326,9 +577,8 @@ Useful for:
|
|
|
326
577
|
|
|
327
578
|
### Known Limitations
|
|
328
579
|
|
|
329
|
-
-
|
|
330
|
-
-
|
|
331
|
-
- Archive/debug/trace methods depend on underlying RPC support
|
|
580
|
+
- `pinnedProvider()` has no automatic failover — if the pinned node goes down, call it again to re-pin
|
|
581
|
+
- Archive, debug, and trace methods work only if the underlying RPC supports them
|
|
332
582
|
|
|
333
583
|
---
|
|
334
584
|
|
|
@@ -380,9 +630,6 @@ Not intended for:
|
|
|
380
630
|
|
|
381
631
|
## Roadmap
|
|
382
632
|
|
|
383
|
-
- Circuit breaker + health scoring
|
|
384
|
-
- Sticky session / blockTag consistency
|
|
385
|
-
- Adaptive latency-based routing
|
|
386
633
|
- Singleflight request deduplication
|
|
387
634
|
|
|
388
635
|
---
|
|
@@ -391,7 +638,7 @@ Not intended for:
|
|
|
391
638
|
|
|
392
639
|
Read the engineering story behind this library:
|
|
393
640
|
|
|
394
|
-
|
|
641
|
+
[How I solved Ethereum RPC rate limits without paying $250/month](https://dev.to/ahiipsa/how-i-solved-ethereum-rpc-rate-limits-with-traffic-engineering-instead-of-paying-250month-30ed)
|
|
395
642
|
|
|
396
643
|
---
|
|
397
644
|
|