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.
Files changed (34) hide show
  1. rpcbench-0.1.0/LICENSE +21 -0
  2. rpcbench-0.1.0/MANIFEST.in +11 -0
  3. rpcbench-0.1.0/PKG-INFO +104 -0
  4. rpcbench-0.1.0/README.md +76 -0
  5. rpcbench-0.1.0/docs/BOUNDARY.md +44 -0
  6. rpcbench-0.1.0/endpoints.ci.yaml +7 -0
  7. rpcbench-0.1.0/endpoints.example.yaml +15 -0
  8. rpcbench-0.1.0/pyproject.toml +49 -0
  9. rpcbench-0.1.0/setup.cfg +4 -0
  10. rpcbench-0.1.0/src/rpcbench/__init__.py +3 -0
  11. rpcbench-0.1.0/src/rpcbench/__main__.py +4 -0
  12. rpcbench-0.1.0/src/rpcbench/cli.py +196 -0
  13. rpcbench-0.1.0/src/rpcbench/config.py +122 -0
  14. rpcbench-0.1.0/src/rpcbench/methods.py +76 -0
  15. rpcbench-0.1.0/src/rpcbench/report.py +364 -0
  16. rpcbench-0.1.0/src/rpcbench/rpc.py +226 -0
  17. rpcbench-0.1.0/src/rpcbench/run.py +240 -0
  18. rpcbench-0.1.0/src/rpcbench/safety.py +54 -0
  19. rpcbench-0.1.0/src/rpcbench/urls.py +64 -0
  20. rpcbench-0.1.0/src/rpcbench.egg-info/PKG-INFO +104 -0
  21. rpcbench-0.1.0/src/rpcbench.egg-info/SOURCES.txt +32 -0
  22. rpcbench-0.1.0/src/rpcbench.egg-info/dependency_links.txt +1 -0
  23. rpcbench-0.1.0/src/rpcbench.egg-info/entry_points.txt +2 -0
  24. rpcbench-0.1.0/src/rpcbench.egg-info/requires.txt +5 -0
  25. rpcbench-0.1.0/src/rpcbench.egg-info/top_level.txt +1 -0
  26. rpcbench-0.1.0/tests/conftest.py +12 -0
  27. rpcbench-0.1.0/tests/fixtures/report.json +218 -0
  28. rpcbench-0.1.0/tests/test_cli.py +520 -0
  29. rpcbench-0.1.0/tests/test_config.py +137 -0
  30. rpcbench-0.1.0/tests/test_json.py +138 -0
  31. rpcbench-0.1.0/tests/test_methods.py +64 -0
  32. rpcbench-0.1.0/tests/test_report.py +159 -0
  33. rpcbench-0.1.0/tests/test_run.py +425 -0
  34. 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]
@@ -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)
@@ -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,7 @@
1
+ # Public Ethereum RPCs for GitHub Actions smoke. No localhost. No API keys.
2
+
3
+ endpoints:
4
+ - name: publicnode
5
+ url: https://ethereum.publicnode.com
6
+ - name: drpc
7
+ url: https://eth.drpc.org
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """RPCBench: vendor-neutral RPC quality comparison CLI."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from rpcbench.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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)