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 CHANGED
@@ -1,5 +1,7 @@
1
1
  [![npm (tag)](https://img.shields.io/npm/v/ethers-rpc-pool)](https://www.npmjs.com/package/ethers-rpc-pool)
2
2
  ![license](https://img.shields.io/npm/l/ethers-rpc-pool)
3
+ [![CI](https://github.com/ahiipsa/ethers-rpc-pool/actions/workflows/ci.yml/badge.svg)](https://github.com/ahiipsa/ethers-rpc-pool/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/ahiipsa/ethers-rpc-pool/branch/main/graph/badge.svg)](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
- - - [Interfaces](#interfaces)
27
- - - [RPCPoolProvider Options](#rpcpoolprovider-options)
28
- - - [JsonRpcProvider Options](#jsonrpcprovider-options)
29
+ - [Interfaces](#interfaces)
30
+ - [RPCPoolProvider Options](#rpcpoolprovider-options)
31
+ - [Per-Endpoint Options](#per-endpoint-options)
29
32
  - [How It Works](#how-it-works)
30
- - - [Routing](#1-routing)
31
- - - [Concurrency Control](#2-concurrency-control)
32
- - - [Rate Limiting](#3-rate-limiting)
33
- - - [Retry Strategy](#4-retry-strategy)
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
- - - [Recommended Settings](#recommended-settings)
37
- - - [Known Limitations](#known-limitations)
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
- - Single point of failure
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
- `ethers-rpc-pool` solves this by introducing:
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
- - Multi-provider routing
56
- - Per-endpoint concurrency limiting
57
- - Intelligent failover
58
- - Retry with exponential backoff + jitter
59
- - Built-in request instrumentation
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
- chainId: 1,
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
- { url: 'https://public-eth.nownodes.io' },
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
- // Use it like a regular `JsonRpcProvider`:
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 RPCParameters {
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 instrumentation hook for provider-level events
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` | Target chain ID |
161
- | `rpc` | List of RPC endpoints |
162
- | `retry.attempts` | Maximum number of unique endpoints to try |
163
- | `defaultRpcOptions` | Default options for all RPC endpoints |
164
- | `hooks.onEvent` | Optional instrumentation hook |
165
-
166
- ### JsonRpcProvider Options
167
-
168
- | Option | Description |
169
- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
170
- | `inFlight` | Max concurrent requests per endpoint |
171
- | `timeout` | Timeout in ms for each request to this URL, default 10s |
172
- | `rps` | Maximum number of requests per second allowed for a single RPC endpoint. Enforced using a token bucket rate limiter. |
173
- | `rpsBurst` | Maximum burst capacity for the rate limiter. Allows short spikes above the sustained rate by accumulating tokens during idle periods. |
174
- | ... | Also allows customization of [ethers.JsonRpcApiProviderOptions](https://docs.ethers.org/v6/api/providers/jsonrpc/#JsonRpcApiProviderOptions) |
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
- Requests are routed through an internal `Router`, which selects an available endpoint.
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 happen on errors considered failover-safe.
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
- You can subscribe to RPC lifecycle events:
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
- ```typescript
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
- console.log(event);
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
- This allows integration with:
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
- - Prometheus
264
- - OpenTelemetry
265
- - Custom logging pipelines
408
+ const registry = new Registry();
266
409
 
267
- ### Access Stats Snapshot
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 stats = pool.getStats();
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
- - Basic circuit breaker/cooldown
330
- - No sticky session/blockTag consistency yet
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
- (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]
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