rpcbench 0.1.0__tar.gz
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.
- rpcbench-0.1.0/LICENSE +21 -0
- rpcbench-0.1.0/MANIFEST.in +11 -0
- rpcbench-0.1.0/PKG-INFO +104 -0
- rpcbench-0.1.0/README.md +76 -0
- rpcbench-0.1.0/docs/BOUNDARY.md +44 -0
- rpcbench-0.1.0/endpoints.ci.yaml +7 -0
- rpcbench-0.1.0/endpoints.example.yaml +15 -0
- rpcbench-0.1.0/pyproject.toml +49 -0
- rpcbench-0.1.0/setup.cfg +4 -0
- rpcbench-0.1.0/src/rpcbench/__init__.py +3 -0
- rpcbench-0.1.0/src/rpcbench/__main__.py +4 -0
- rpcbench-0.1.0/src/rpcbench/cli.py +196 -0
- rpcbench-0.1.0/src/rpcbench/config.py +122 -0
- rpcbench-0.1.0/src/rpcbench/methods.py +76 -0
- rpcbench-0.1.0/src/rpcbench/report.py +364 -0
- rpcbench-0.1.0/src/rpcbench/rpc.py +226 -0
- rpcbench-0.1.0/src/rpcbench/run.py +240 -0
- rpcbench-0.1.0/src/rpcbench/safety.py +54 -0
- rpcbench-0.1.0/src/rpcbench/urls.py +64 -0
- rpcbench-0.1.0/src/rpcbench.egg-info/PKG-INFO +104 -0
- rpcbench-0.1.0/src/rpcbench.egg-info/SOURCES.txt +32 -0
- rpcbench-0.1.0/src/rpcbench.egg-info/dependency_links.txt +1 -0
- rpcbench-0.1.0/src/rpcbench.egg-info/entry_points.txt +2 -0
- rpcbench-0.1.0/src/rpcbench.egg-info/requires.txt +5 -0
- rpcbench-0.1.0/src/rpcbench.egg-info/top_level.txt +1 -0
- rpcbench-0.1.0/tests/conftest.py +12 -0
- rpcbench-0.1.0/tests/fixtures/report.json +218 -0
- rpcbench-0.1.0/tests/test_cli.py +520 -0
- rpcbench-0.1.0/tests/test_config.py +137 -0
- rpcbench-0.1.0/tests/test_json.py +138 -0
- rpcbench-0.1.0/tests/test_methods.py +64 -0
- rpcbench-0.1.0/tests/test_report.py +159 -0
- rpcbench-0.1.0/tests/test_run.py +425 -0
- rpcbench-0.1.0/tests/test_safety.py +28 -0
rpcbench-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ehsan Hajian
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
include LICENSE
|
|
2
|
+
include README.md
|
|
3
|
+
include pyproject.toml
|
|
4
|
+
include endpoints.example.yaml
|
|
5
|
+
include endpoints.ci.yaml
|
|
6
|
+
graft tests
|
|
7
|
+
graft docs
|
|
8
|
+
global-exclude endpoints.yaml
|
|
9
|
+
global-exclude endpoints.json
|
|
10
|
+
global-exclude __pycache__
|
|
11
|
+
global-exclude *.py[cod]
|
rpcbench-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rpcbench
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Compare EVM JSON-RPC endpoints: latency, percentiles, errors, ranked CLI and JSON.
|
|
5
|
+
Author: Ehsan Hajian
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ehsanhajian/RPCBench
|
|
8
|
+
Project-URL: Repository, https://github.com/ehsanhajian/RPCBench
|
|
9
|
+
Project-URL: Issues, https://github.com/ehsanhajian/RPCBench/issues
|
|
10
|
+
Keywords: rpc,ethereum,json-rpc,benchmark,latency
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: System :: Benchmark
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Requires-Dist: PyYAML>=6.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# RPCBench
|
|
30
|
+
|
|
31
|
+
**Which RPC endpoint is fastest — for this call, from this machine?**
|
|
32
|
+
|
|
33
|
+
RPCBench 0.1 is a small CLI that compares **EVM JSON-RPC over HTTP**: round-trip latency, P50/P95/P99, error rate, a ranked table, and JSON. Read-only by default. No accounts. No telemetry. Localhost and RFC1918 are allowed (that is how you bench your own node).
|
|
34
|
+
|
|
35
|
+
It is **not** a security scanner ([Nodeprobe](https://github.com/ehsanhajian/nodeprobe)) and **not** validator monitoring ([ValidatorPulse](https://github.com/ehsanhajian/ValidatorPulse)). How the three tools split: [docs/BOUNDARY.md](https://github.com/ehsanhajian/RPCBench/blob/main/docs/BOUNDARY.md).
|
|
36
|
+
|
|
37
|
+
The longer product (other families, workload mixes, HTML/TUI, production verdict) lives in the [issue tracker](https://github.com/ehsanhajian/RPCBench/issues). Parent epic: [#19](https://github.com/ehsanhajian/RPCBench/issues/19).
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install rpcbench
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Requires Python 3.10+. `rpcbench --version` prints `0.1.0`.
|
|
46
|
+
|
|
47
|
+
From a clone (contributors):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python3 -m venv .venv
|
|
51
|
+
source .venv/bin/activate
|
|
52
|
+
pip install -e ".[dev]"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Pull requests and pushes to `main` run `pytest`, then a live smoke: `rpcbench run` against PublicNode and dRPC (`--samples 1 --warmup 0`). There is no local node in CI. The smoke passes if either public endpoint is ok.
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
rpcbench compare --endpoints https://ethereum.publicnode.com --samples 5 --warmup 1
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or a YAML/JSON file of named endpoints (keep API keys in a **local** file; do not commit it):
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
endpoints:
|
|
67
|
+
- name: publicnode
|
|
68
|
+
url: https://ethereum.publicnode.com
|
|
69
|
+
- name: drpc
|
|
70
|
+
url: https://eth.drpc.org
|
|
71
|
+
- name: paid
|
|
72
|
+
url: https://eth.example/v3/YOUR_KEY
|
|
73
|
+
bearer: YOUR_TOKEN
|
|
74
|
+
headers:
|
|
75
|
+
X-Api-Key: YOUR_KEY
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
rpcbench run --endpoints endpoints.yaml
|
|
80
|
+
rpcbench compare --endpoints endpoints.yaml --json
|
|
81
|
+
rpcbench run --endpoints endpoints.yaml -o report.json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`run` and `compare` are the same command. They print **summary**, **ranking**, **per-provider metrics**, and **method coverage**. Ranking is by **mean of successful samples** (warmup excluded); failed endpoints are last; ties keep config order. On a TTY, ok is green and fail is red (`NO_COLOR` or a pipe disables this). Reports print a redacted URL plus a short hash (`id=`), never API keys, bearer tokens, or header values.
|
|
85
|
+
|
|
86
|
+
### Flags
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
rpcbench run --endpoints endpoints.yaml --samples 10 --warmup 1 --preset head --timeout 10 --budget 128
|
|
90
|
+
rpcbench compare --endpoints http://127.0.0.1:8545
|
|
91
|
+
rpcbench run --endpoints endpoints.yaml --verbose --json
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`--samples` timed requests per endpoint after `--warmup` (defaults: 10 and 1). Warmup is excluded from min/mean/max, percentiles, and error rate. P50/P95/P99 are nearest-rank over successful samples. Error rate is failed/attempted with a class breakdown (timeout, connection, HTTP 4xx/5xx, JSON-RPC, malformed). `--verbose` prints each sample. `--preset` is `head` (`eth_blockNumber`), `chainId`, or `balance` (`eth_getBalance` of the zero address). Or pass `--method` and optional `--params` (JSON array). Write methods are rejected unless `--allow-writes`.
|
|
95
|
+
|
|
96
|
+
`--json` prints a machine-readable report to stdout instead of the table. `-o FILE` writes that JSON to a file (the table still prints unless you also pass `--json`). Sequential `rps` is `1000 / mean_ms`. Reliability `score` is success rate.
|
|
97
|
+
|
|
98
|
+
`--budget` is the max HTTP requests for the whole run, including warmup (default 128, hard cap `RPCBENCH_MAX_REQUESTS` default 10000). `--max-duration` stops remaining work after N seconds and still prints the report (default 600; `0` = no limit). `--concurrency` is 1.
|
|
99
|
+
|
|
100
|
+
Kill switch: set `RPCBENCH_DISABLED=1`, or create `~/.config/rpcbench/DISABLED` (override path with `RPCBENCH_DISABLE_FILE`). RPCBench never prompts for a private key.
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
[MIT](https://github.com/ehsanhajian/RPCBench/blob/main/LICENSE)
|
rpcbench-0.1.0/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# RPCBench
|
|
2
|
+
|
|
3
|
+
**Which RPC endpoint is fastest — for this call, from this machine?**
|
|
4
|
+
|
|
5
|
+
RPCBench 0.1 is a small CLI that compares **EVM JSON-RPC over HTTP**: round-trip latency, P50/P95/P99, error rate, a ranked table, and JSON. Read-only by default. No accounts. No telemetry. Localhost and RFC1918 are allowed (that is how you bench your own node).
|
|
6
|
+
|
|
7
|
+
It is **not** a security scanner ([Nodeprobe](https://github.com/ehsanhajian/nodeprobe)) and **not** validator monitoring ([ValidatorPulse](https://github.com/ehsanhajian/ValidatorPulse)). How the three tools split: [docs/BOUNDARY.md](https://github.com/ehsanhajian/RPCBench/blob/main/docs/BOUNDARY.md).
|
|
8
|
+
|
|
9
|
+
The longer product (other families, workload mixes, HTML/TUI, production verdict) lives in the [issue tracker](https://github.com/ehsanhajian/RPCBench/issues). Parent epic: [#19](https://github.com/ehsanhajian/RPCBench/issues/19).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install rpcbench
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Python 3.10+. `rpcbench --version` prints `0.1.0`.
|
|
18
|
+
|
|
19
|
+
From a clone (contributors):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
python3 -m venv .venv
|
|
23
|
+
source .venv/bin/activate
|
|
24
|
+
pip install -e ".[dev]"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Pull requests and pushes to `main` run `pytest`, then a live smoke: `rpcbench run` against PublicNode and dRPC (`--samples 1 --warmup 0`). There is no local node in CI. The smoke passes if either public endpoint is ok.
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
rpcbench compare --endpoints https://ethereum.publicnode.com --samples 5 --warmup 1
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or a YAML/JSON file of named endpoints (keep API keys in a **local** file; do not commit it):
|
|
36
|
+
|
|
37
|
+
```yaml
|
|
38
|
+
endpoints:
|
|
39
|
+
- name: publicnode
|
|
40
|
+
url: https://ethereum.publicnode.com
|
|
41
|
+
- name: drpc
|
|
42
|
+
url: https://eth.drpc.org
|
|
43
|
+
- name: paid
|
|
44
|
+
url: https://eth.example/v3/YOUR_KEY
|
|
45
|
+
bearer: YOUR_TOKEN
|
|
46
|
+
headers:
|
|
47
|
+
X-Api-Key: YOUR_KEY
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
rpcbench run --endpoints endpoints.yaml
|
|
52
|
+
rpcbench compare --endpoints endpoints.yaml --json
|
|
53
|
+
rpcbench run --endpoints endpoints.yaml -o report.json
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`run` and `compare` are the same command. They print **summary**, **ranking**, **per-provider metrics**, and **method coverage**. Ranking is by **mean of successful samples** (warmup excluded); failed endpoints are last; ties keep config order. On a TTY, ok is green and fail is red (`NO_COLOR` or a pipe disables this). Reports print a redacted URL plus a short hash (`id=`), never API keys, bearer tokens, or header values.
|
|
57
|
+
|
|
58
|
+
### Flags
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
rpcbench run --endpoints endpoints.yaml --samples 10 --warmup 1 --preset head --timeout 10 --budget 128
|
|
62
|
+
rpcbench compare --endpoints http://127.0.0.1:8545
|
|
63
|
+
rpcbench run --endpoints endpoints.yaml --verbose --json
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`--samples` timed requests per endpoint after `--warmup` (defaults: 10 and 1). Warmup is excluded from min/mean/max, percentiles, and error rate. P50/P95/P99 are nearest-rank over successful samples. Error rate is failed/attempted with a class breakdown (timeout, connection, HTTP 4xx/5xx, JSON-RPC, malformed). `--verbose` prints each sample. `--preset` is `head` (`eth_blockNumber`), `chainId`, or `balance` (`eth_getBalance` of the zero address). Or pass `--method` and optional `--params` (JSON array). Write methods are rejected unless `--allow-writes`.
|
|
67
|
+
|
|
68
|
+
`--json` prints a machine-readable report to stdout instead of the table. `-o FILE` writes that JSON to a file (the table still prints unless you also pass `--json`). Sequential `rps` is `1000 / mean_ms`. Reliability `score` is success rate.
|
|
69
|
+
|
|
70
|
+
`--budget` is the max HTTP requests for the whole run, including warmup (default 128, hard cap `RPCBENCH_MAX_REQUESTS` default 10000). `--max-duration` stops remaining work after N seconds and still prints the report (default 600; `0` = no limit). `--concurrency` is 1.
|
|
71
|
+
|
|
72
|
+
Kill switch: set `RPCBENCH_DISABLED=1`, or create `~/.config/rpcbench/DISABLED` (override path with `RPCBENCH_DISABLE_FILE`). RPCBench never prompts for a private key.
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
[MIT](https://github.com/ehsanhajian/RPCBench/blob/main/LICENSE)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Boundary: RPCBench vs Nodeprobe vs ValidatorPulse
|
|
2
|
+
|
|
3
|
+
Three tools, three questions. Do not copy checks across the line.
|
|
4
|
+
|
|
5
|
+
| Tool | Question | Never does |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| **[Nodeprobe](https://github.com/ehsanhajian/nodeprobe)** | Is this RPC **safe to expose**? | Latency percentiles, load, ranking providers |
|
|
8
|
+
| **RPCBench** | Which RPC **performs best** for this workload? | Security findings, privileged-namespace probes, TLS/CORS, CVE/client disclosure |
|
|
9
|
+
| **[ValidatorPulse](https://github.com/ehsanhajian/ValidatorPulse)** | Is **my validator** healthy? | Scanning other people’s RPCs; comparing providers |
|
|
10
|
+
|
|
11
|
+
## Nodeprobe owns (RPCBench must not implement)
|
|
12
|
+
|
|
13
|
+
- Privileged namespace **presence** as a finding: `admin_*`, `personal_*`, `miner_*`, `engine_*`, `txpool_*`, `clique_*`, `eth_accounts`, Solana `validatorExit` / `setLogFilter`, Cosmos unsafe, NEAR adversarial, Starknet devnet, Substrate key injection
|
|
14
|
+
- TLS, CORS, `Server` header, `rpc_modules` disclosure, outdated-client / CVE recon
|
|
15
|
+
- Security score (0–100), severity (Critical/High/…), escalation (`↳ Next:`)
|
|
16
|
+
- Deep **method inventory as attack surface**
|
|
17
|
+
- Blocking private/localhost targets (Nodeprobe anti-SSRF). RPCBench **must allow localhost** — you bench your own node
|
|
18
|
+
- `--block-providers`, unauthorized-scan warnings as a product feature
|
|
19
|
+
- Kill switch path or rule IDs copied from Nodeprobe
|
|
20
|
+
|
|
21
|
+
## RPCBench owns (even if a method name appears in both)
|
|
22
|
+
|
|
23
|
+
- Timed samples: P50/P95/P99, jitter, histograms, RPS, batch, load shapes
|
|
24
|
+
- Fair paired compare, similar-band, body/hash **consistency** (correctness under load, not “exposed API”)
|
|
25
|
+
- Head freshness / lag vs cohort; `latest` vs `safe` vs `finalized` **latency**
|
|
26
|
+
- Archive / historical **read performance** (can this indexer finish, and how slow)
|
|
27
|
+
- WebSocket **subscribe latency** and missed slots — Nodeprobe is HTTP-only
|
|
28
|
+
- Rate limits as **reliability under a budgeted burst**, not “abuse posture”
|
|
29
|
+
- Workload coverage: of the methods **this mix needs**, which ones succeeded and how fast
|
|
30
|
+
- Optional **trace/debug timing** only when the user opts into a tracing/indexer mix — skip if missing, never a vulnerability
|
|
31
|
+
- Client version as a **report label** (interpret Erigon vs Geth results), never a disclosure finding
|
|
32
|
+
- HTML/JSON/Prometheus as **benchmark reports**, not finding cards with severity badges
|
|
33
|
+
|
|
34
|
+
## Shared primitives (OK if the purpose differs)
|
|
35
|
+
|
|
36
|
+
Identity calls (`eth_chainId`, `getHealth`, `system_health`) to pick a family and confirm the network. Same JSON-RPC, different question.
|
|
37
|
+
|
|
38
|
+
## Profile names
|
|
39
|
+
|
|
40
|
+
Nodeprobe: `--profile Quick|Standard|Deep` = **scan budget / escalation**.
|
|
41
|
+
|
|
42
|
+
RPCBench: `--budget short|standard|long` = **sample count / duration**. Do not reuse Quick/Standard/Deep.
|
|
43
|
+
|
|
44
|
+
Workload mixes (`general`, `wallet`, `indexer`, `trading`, `nft`, optional `tracing`) are RPCBench-only.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Named RPC endpoints. Localhost and RFC1918 are allowed.
|
|
2
|
+
# Copy to endpoints.yaml and edit. JSON with the same shape also works.
|
|
3
|
+
# Secrets in query strings, path tokens, userinfo, bearer, and headers
|
|
4
|
+
# are stripped from reports; a short hash of the raw URL is kept instead.
|
|
5
|
+
|
|
6
|
+
endpoints:
|
|
7
|
+
- name: local
|
|
8
|
+
url: http://127.0.0.1:8545
|
|
9
|
+
- name: publicnode
|
|
10
|
+
url: https://ethereum.publicnode.com
|
|
11
|
+
# - name: paid
|
|
12
|
+
# url: https://eth.example/v3/YOUR_KEY
|
|
13
|
+
# bearer: YOUR_TOKEN
|
|
14
|
+
# headers:
|
|
15
|
+
# X-Api-Key: YOUR_KEY
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rpcbench"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Compare EVM JSON-RPC endpoints: latency, percentiles, errors, ranked CLI and JSON."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Ehsan Hajian" }]
|
|
14
|
+
keywords = ["rpc", "ethereum", "json-rpc", "benchmark", "latency"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: System :: Benchmark",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"httpx>=0.27",
|
|
28
|
+
"PyYAML>=6.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=8.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
rpcbench = "rpcbench.cli:main"
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/ehsanhajian/RPCBench"
|
|
41
|
+
Repository = "https://github.com/ehsanhajian/RPCBench"
|
|
42
|
+
Issues = "https://github.com/ehsanhajian/RPCBench/issues"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.packages.find]
|
|
45
|
+
where = ["src"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
49
|
+
pythonpath = ["src"]
|
rpcbench-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""rpcbench CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from rpcbench import __version__
|
|
10
|
+
from rpcbench.config import ConfigError, load_targets
|
|
11
|
+
from rpcbench.methods import MethodError, resolve_method
|
|
12
|
+
from rpcbench.report import format_json, format_run
|
|
13
|
+
from rpcbench.run import run_endpoints
|
|
14
|
+
from rpcbench.safety import SafetyError, check_budget, kill_switch_reason
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="rpcbench",
|
|
20
|
+
description="Measure RPC quality and compare providers.",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument("--version", action="version", version=f"rpcbench {__version__}")
|
|
23
|
+
sub = parser.add_subparsers(dest="command")
|
|
24
|
+
_add_run_parser(
|
|
25
|
+
sub,
|
|
26
|
+
"run",
|
|
27
|
+
"Measure JSON-RPC round-trip latency and print a comparison report",
|
|
28
|
+
)
|
|
29
|
+
_add_run_parser(
|
|
30
|
+
sub,
|
|
31
|
+
"compare",
|
|
32
|
+
"Same as run: print a ranked CLI report for configured endpoints",
|
|
33
|
+
)
|
|
34
|
+
return parser
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _add_run_parser(sub, name: str, help_text: str) -> None:
|
|
38
|
+
run = sub.add_parser(name, help=help_text)
|
|
39
|
+
run.add_argument(
|
|
40
|
+
"--endpoints",
|
|
41
|
+
required=True,
|
|
42
|
+
metavar="FILE|URL",
|
|
43
|
+
help="YAML/JSON file, or a single http(s) URL (localhost is allowed)",
|
|
44
|
+
)
|
|
45
|
+
run.add_argument(
|
|
46
|
+
"--method",
|
|
47
|
+
default=None,
|
|
48
|
+
help="JSON-RPC method (default: eth_blockNumber). Do not combine with --preset.",
|
|
49
|
+
)
|
|
50
|
+
run.add_argument(
|
|
51
|
+
"--preset",
|
|
52
|
+
default=None,
|
|
53
|
+
metavar="NAME",
|
|
54
|
+
help="Read-only method pack: head, chainId, or balance",
|
|
55
|
+
)
|
|
56
|
+
run.add_argument(
|
|
57
|
+
"--params",
|
|
58
|
+
default=None,
|
|
59
|
+
metavar="JSON",
|
|
60
|
+
help='JSON array of params, e.g. \'["0x0","latest"]\'',
|
|
61
|
+
)
|
|
62
|
+
run.add_argument(
|
|
63
|
+
"--allow-writes",
|
|
64
|
+
action="store_true",
|
|
65
|
+
help="Allow write methods (eth_send*, personal_*, …). Default is read-only.",
|
|
66
|
+
)
|
|
67
|
+
run.add_argument(
|
|
68
|
+
"--samples",
|
|
69
|
+
type=int,
|
|
70
|
+
default=10,
|
|
71
|
+
help="Timed samples per endpoint after warmup (default: 10)",
|
|
72
|
+
)
|
|
73
|
+
run.add_argument(
|
|
74
|
+
"--warmup",
|
|
75
|
+
type=int,
|
|
76
|
+
default=1,
|
|
77
|
+
help="Warmup requests excluded from stats (default: 1)",
|
|
78
|
+
)
|
|
79
|
+
run.add_argument(
|
|
80
|
+
"--timeout",
|
|
81
|
+
type=float,
|
|
82
|
+
default=10.0,
|
|
83
|
+
help="Per-request timeout in seconds (default: 10)",
|
|
84
|
+
)
|
|
85
|
+
run.add_argument(
|
|
86
|
+
"--budget",
|
|
87
|
+
type=int,
|
|
88
|
+
default=128,
|
|
89
|
+
help="Max HTTP requests for the whole run, including warmup (default: 128)",
|
|
90
|
+
)
|
|
91
|
+
run.add_argument(
|
|
92
|
+
"--max-duration",
|
|
93
|
+
type=float,
|
|
94
|
+
default=600.0,
|
|
95
|
+
metavar="SEC",
|
|
96
|
+
help="Stop the run after this many seconds and still print a report (default: 600; 0 = no limit)",
|
|
97
|
+
)
|
|
98
|
+
run.add_argument(
|
|
99
|
+
"--concurrency",
|
|
100
|
+
type=int,
|
|
101
|
+
default=1,
|
|
102
|
+
help="Max in-flight requests (only 1 is supported)",
|
|
103
|
+
)
|
|
104
|
+
run.add_argument(
|
|
105
|
+
"--verbose",
|
|
106
|
+
"-v",
|
|
107
|
+
action="store_true",
|
|
108
|
+
help="Print per-sample latency and error-class detail",
|
|
109
|
+
)
|
|
110
|
+
run.add_argument(
|
|
111
|
+
"--json",
|
|
112
|
+
action="store_true",
|
|
113
|
+
help="Print a JSON report to stdout instead of the CLI table",
|
|
114
|
+
)
|
|
115
|
+
run.add_argument(
|
|
116
|
+
"-o",
|
|
117
|
+
"--output",
|
|
118
|
+
metavar="FILE",
|
|
119
|
+
help="Write the JSON report to FILE (CLI table still prints unless --json)",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main(argv: list[str] | None = None) -> int:
|
|
124
|
+
parser = build_parser()
|
|
125
|
+
args = parser.parse_args(argv)
|
|
126
|
+
if args.command is None:
|
|
127
|
+
parser.print_help()
|
|
128
|
+
return 2
|
|
129
|
+
if args.command in {"run", "compare"}:
|
|
130
|
+
return _cmd_run(args)
|
|
131
|
+
parser.print_help()
|
|
132
|
+
return 2
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
136
|
+
stopped = kill_switch_reason()
|
|
137
|
+
if stopped:
|
|
138
|
+
print(f"rpcbench: disabled ({stopped})", file=sys.stderr)
|
|
139
|
+
return 2
|
|
140
|
+
try:
|
|
141
|
+
check_budget(args.budget)
|
|
142
|
+
config = load_targets(args.endpoints)
|
|
143
|
+
method, params = resolve_method(
|
|
144
|
+
method=args.method,
|
|
145
|
+
preset=args.preset,
|
|
146
|
+
params_json=args.params,
|
|
147
|
+
allow_writes=args.allow_writes,
|
|
148
|
+
)
|
|
149
|
+
except (ConfigError, MethodError, SafetyError) as exc:
|
|
150
|
+
print(f"rpcbench: {exc}", file=sys.stderr)
|
|
151
|
+
return 2
|
|
152
|
+
if (
|
|
153
|
+
args.timeout <= 0
|
|
154
|
+
or args.budget < 1
|
|
155
|
+
or args.samples < 1
|
|
156
|
+
or args.warmup < 0
|
|
157
|
+
or args.max_duration < 0
|
|
158
|
+
):
|
|
159
|
+
print(
|
|
160
|
+
"rpcbench: --timeout must be > 0, --samples >= 1, "
|
|
161
|
+
"--warmup >= 0, --budget >= 1, --max-duration >= 0",
|
|
162
|
+
file=sys.stderr,
|
|
163
|
+
)
|
|
164
|
+
return 2
|
|
165
|
+
if args.concurrency != 1:
|
|
166
|
+
print("rpcbench: only --concurrency 1 is supported", file=sys.stderr)
|
|
167
|
+
return 2
|
|
168
|
+
result = run_endpoints(
|
|
169
|
+
config,
|
|
170
|
+
method=method,
|
|
171
|
+
params=params,
|
|
172
|
+
samples=args.samples,
|
|
173
|
+
warmup=args.warmup,
|
|
174
|
+
timeout=args.timeout,
|
|
175
|
+
budget=args.budget,
|
|
176
|
+
max_duration=args.max_duration,
|
|
177
|
+
)
|
|
178
|
+
payload = format_json(result) if (args.json or args.output) else None
|
|
179
|
+
if args.output:
|
|
180
|
+
path = Path(args.output)
|
|
181
|
+
try:
|
|
182
|
+
path.write_text(payload or "", encoding="utf-8")
|
|
183
|
+
except OSError as exc:
|
|
184
|
+
print(f"rpcbench: cannot write {path}: {exc}", file=sys.stderr)
|
|
185
|
+
return 2
|
|
186
|
+
if args.json:
|
|
187
|
+
sys.stdout.write(payload or "")
|
|
188
|
+
else:
|
|
189
|
+
sys.stdout.write(format_run(result, verbose=args.verbose))
|
|
190
|
+
if any(outcome.stats.n_ok for outcome in result.outcomes):
|
|
191
|
+
return 0
|
|
192
|
+
return 1
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Load named RPC endpoints from YAML or JSON. Localhost is allowed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from rpcbench.urls import display_url, url_fingerprint
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigError(ValueError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Endpoint:
|
|
21
|
+
name: str
|
|
22
|
+
url: str
|
|
23
|
+
headers: tuple[tuple[str, str], ...] = ()
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def display_url(self) -> str:
|
|
27
|
+
return display_url(self.url)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def url_id(self) -> str:
|
|
31
|
+
return url_fingerprint(self.url)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class BenchConfig:
|
|
36
|
+
endpoints: tuple[Endpoint, ...]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_targets(spec: str | Path) -> BenchConfig:
|
|
40
|
+
text = str(spec).strip()
|
|
41
|
+
if text.startswith("http://") or text.startswith("https://"):
|
|
42
|
+
host = urlsplit(text).hostname or "cli"
|
|
43
|
+
return parse_endpoints(
|
|
44
|
+
{"endpoints": [{"name": host, "url": text}]},
|
|
45
|
+
source="cli",
|
|
46
|
+
)
|
|
47
|
+
return load_endpoints(text)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_endpoints(path: str | Path) -> BenchConfig:
|
|
51
|
+
raw_path = Path(path)
|
|
52
|
+
if not raw_path.is_file():
|
|
53
|
+
raise ConfigError(f"endpoints file not found: {raw_path}")
|
|
54
|
+
text = raw_path.read_text(encoding="utf-8")
|
|
55
|
+
suffix = raw_path.suffix.lower()
|
|
56
|
+
try:
|
|
57
|
+
if suffix == ".json":
|
|
58
|
+
data = json.loads(text)
|
|
59
|
+
else:
|
|
60
|
+
data = yaml.safe_load(text)
|
|
61
|
+
except (json.JSONDecodeError, yaml.YAMLError) as exc:
|
|
62
|
+
raise ConfigError(f"invalid endpoints file {raw_path}: {exc}") from exc
|
|
63
|
+
return parse_endpoints(data, source=str(raw_path))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_endpoints(data: object, *, source: str = "config") -> BenchConfig:
|
|
67
|
+
if not isinstance(data, dict):
|
|
68
|
+
raise ConfigError(f"{source}: expected a mapping with an 'endpoints' list")
|
|
69
|
+
items = data.get("endpoints")
|
|
70
|
+
if not isinstance(items, list) or not items:
|
|
71
|
+
raise ConfigError(f"{source}: 'endpoints' must be a non-empty list")
|
|
72
|
+
seen: set[str] = set()
|
|
73
|
+
endpoints: list[Endpoint] = []
|
|
74
|
+
for i, item in enumerate(items):
|
|
75
|
+
if not isinstance(item, dict):
|
|
76
|
+
raise ConfigError(f"{source}: endpoints[{i}] must be a mapping")
|
|
77
|
+
name = item.get("name")
|
|
78
|
+
url = item.get("url")
|
|
79
|
+
if not isinstance(name, str) or not name.strip():
|
|
80
|
+
raise ConfigError(f"{source}: endpoints[{i}].name is required")
|
|
81
|
+
if not isinstance(url, str) or not url.strip():
|
|
82
|
+
raise ConfigError(f"{source}: endpoints[{i}].url is required")
|
|
83
|
+
name = name.strip()
|
|
84
|
+
url = url.strip()
|
|
85
|
+
if name in seen:
|
|
86
|
+
raise ConfigError(f"{source}: duplicate endpoint name {name!r}")
|
|
87
|
+
scheme = url.split(":", 1)[0].lower()
|
|
88
|
+
if scheme not in {"http", "https"}:
|
|
89
|
+
raise ConfigError(
|
|
90
|
+
f"{source}: endpoints[{i}] ({name}) URL must be http or https"
|
|
91
|
+
)
|
|
92
|
+
headers = _parse_headers(item, source=source, index=i, name=name)
|
|
93
|
+
seen.add(name)
|
|
94
|
+
endpoints.append(Endpoint(name=name, url=url, headers=headers))
|
|
95
|
+
return BenchConfig(endpoints=tuple(endpoints))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_headers(
|
|
99
|
+
item: dict, *, source: str, index: int, name: str
|
|
100
|
+
) -> tuple[tuple[str, str], ...]:
|
|
101
|
+
headers: list[tuple[str, str]] = []
|
|
102
|
+
raw = item.get("headers")
|
|
103
|
+
if raw is not None:
|
|
104
|
+
if not isinstance(raw, dict):
|
|
105
|
+
raise ConfigError(
|
|
106
|
+
f"{source}: endpoints[{index}] ({name}) headers must be a mapping"
|
|
107
|
+
)
|
|
108
|
+
for key, value in raw.items():
|
|
109
|
+
if not isinstance(key, str) or not isinstance(value, str):
|
|
110
|
+
raise ConfigError(
|
|
111
|
+
f"{source}: endpoints[{index}] ({name}) header names and values "
|
|
112
|
+
"must be strings"
|
|
113
|
+
)
|
|
114
|
+
headers.append((key.strip(), value))
|
|
115
|
+
bearer = item.get("bearer")
|
|
116
|
+
if bearer is not None:
|
|
117
|
+
if not isinstance(bearer, str) or not bearer.strip():
|
|
118
|
+
raise ConfigError(
|
|
119
|
+
f"{source}: endpoints[{index}] ({name}) bearer must be a string"
|
|
120
|
+
)
|
|
121
|
+
headers.append(("Authorization", f"Bearer {bearer.strip()}"))
|
|
122
|
+
return tuple(headers)
|