rpcbench 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- rpcbench/__main__.py +4 -0
- rpcbench/cli.py +196 -0
- rpcbench/config.py +122 -0
- rpcbench/methods.py +76 -0
- rpcbench/report.py +364 -0
- rpcbench/rpc.py +226 -0
- rpcbench/run.py +240 -0
- rpcbench/safety.py +54 -0
- rpcbench/urls.py +64 -0
- rpcbench-0.1.0.dist-info/METADATA +104 -0
- rpcbench-0.1.0.dist-info/RECORD +16 -0
- rpcbench-0.1.0.dist-info/WHEEL +5 -0
- rpcbench-0.1.0.dist-info/entry_points.txt +2 -0
- rpcbench-0.1.0.dist-info/licenses/LICENSE +21 -0
- rpcbench-0.1.0.dist-info/top_level.txt +1 -0
rpcbench/__init__.py
ADDED
rpcbench/__main__.py
ADDED
rpcbench/cli.py
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())
|
rpcbench/config.py
ADDED
|
@@ -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)
|
rpcbench/methods.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Read-only JSON-RPC methods used for latency probes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
|
|
9
|
+
|
|
10
|
+
# Presets: chain head, identity, and a cheap account read.
|
|
11
|
+
PRESETS: dict[str, tuple[str, list[Any]]] = {
|
|
12
|
+
"head": ("eth_blockNumber", []),
|
|
13
|
+
"chainId": ("eth_chainId", []),
|
|
14
|
+
"balance": ("eth_getBalance", [ZERO_ADDRESS, "latest"]),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
_WRITE_PREFIXES = (
|
|
18
|
+
"eth_send",
|
|
19
|
+
"eth_sign",
|
|
20
|
+
"personal_",
|
|
21
|
+
"miner_",
|
|
22
|
+
"admin_",
|
|
23
|
+
"wallet_",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MethodError(ValueError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_method(
|
|
32
|
+
*,
|
|
33
|
+
method: str | None,
|
|
34
|
+
preset: str | None,
|
|
35
|
+
params_json: str | None,
|
|
36
|
+
allow_writes: bool = False,
|
|
37
|
+
) -> tuple[str, list[Any]]:
|
|
38
|
+
if preset and method:
|
|
39
|
+
raise MethodError("use either --preset or --method, not both")
|
|
40
|
+
if preset:
|
|
41
|
+
key = preset.strip().lower()
|
|
42
|
+
matched = next((name for name in PRESETS if name.lower() == key), None)
|
|
43
|
+
if matched is None:
|
|
44
|
+
known = ", ".join(sorted(PRESETS))
|
|
45
|
+
raise MethodError(f"unknown preset {preset!r} (try {known})")
|
|
46
|
+
name, params = PRESETS[matched]
|
|
47
|
+
if params_json:
|
|
48
|
+
params = parse_params(params_json)
|
|
49
|
+
if not allow_writes:
|
|
50
|
+
_reject_writes(name)
|
|
51
|
+
return name, params
|
|
52
|
+
name = (method or "eth_blockNumber").strip()
|
|
53
|
+
if not name:
|
|
54
|
+
raise MethodError("method is required")
|
|
55
|
+
if not allow_writes:
|
|
56
|
+
_reject_writes(name)
|
|
57
|
+
params = parse_params(params_json) if params_json else []
|
|
58
|
+
return name, params
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_params(raw: str) -> list[Any]:
|
|
62
|
+
try:
|
|
63
|
+
data = json.loads(raw)
|
|
64
|
+
except json.JSONDecodeError as exc:
|
|
65
|
+
raise MethodError(f"invalid --params JSON: {exc}") from exc
|
|
66
|
+
if not isinstance(data, list):
|
|
67
|
+
raise MethodError("--params must be a JSON array")
|
|
68
|
+
return data
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _reject_writes(method: str) -> None:
|
|
72
|
+
lower = method.lower()
|
|
73
|
+
if any(lower.startswith(p) for p in _WRITE_PREFIXES):
|
|
74
|
+
raise MethodError(
|
|
75
|
+
f"{method} is a write method; pass --allow-writes to run it anyway"
|
|
76
|
+
)
|