Core
gRPC load testing
perfscale drives gRPC endpoints under load with the native step engine: open HTTP/2 channels, make unary calls from JSON templates, run client/server/bidi streams, and measure both the request round trip and the application-level message RTT alongside your HTTP/TCP/UDP/WebSocket metrics.
Calls are dynamic: no protobuf codegen. The schema arrives at run time (from a descriptor set or server reflection), requests and responses are JSON, and perfscale maps between the two using protobuf-JSON rules.
This page is the guide — what the pieces are and when to reach for which.
Per-step parameters, outputs, and error semantics live in the
actions reference;
a runnable scenario ships as
examples/grpc.test.yaml, together with a
local echo server to run it against
(cargo run -p perfscale-core --example grpc_echo_server).
Two styles
- One-shot call (
std/grpc@v1) — connect, load the schema, make one unary call, close in one step. Simplest for occasional probes. - Live channel (
std/grpc-connect@v1+ friends) — a channel held across steps within an iteration, addressed by the id the connect step returns. Unary calls and streams ride the same HTTP/2 connection: the connect and the schema load are paid once per iteration, not per call. Use this for any serious load.
Freely mixable in one scenario.
Schema sources
Dynamic calls need the protobuf schema at run time. Both connect-capable
steps (std/grpc@v1, std/grpc-connect@v1) take exactly one source — the
two are mutually exclusive:
-
descriptor_set— base64 of a serializedFileDescriptorSet. Produce one from your protos:protoc --descriptor_set_out=echo.pb --include_imports echo.proto base64 -i echo.pb # macOS; on Linux: base64 -w0 echo.pbOr fetch it over HTTP in an earlier step and pipe the binary body straight in — a
std/http@v1step returns binary payloads asbody_base64:steps: - name: fetch schema use: std/http@v1 with: { url: "https://schema.example.com/echo.pb" } outputs: fetch - name: unary probe use: std/grpc@v1 with: url: grpcs://api.example.com:443 descriptor_set: "${{ fetch.body_base64 }}" method: "echo.v1.Echo/Unary" payload: { message: "ping ${seq}" } check: duration_ms_lt: 500 -
reflection: true— fetch the schema from the server's reflection service (v1 protocol); the server must have reflection enabled. The fetched pool is cached per URL for the rest of the run, so repeated connects to one server pay one reflection round trip.
A bad descriptor_set fails fast, before any network I/O. Methods are named
"package.Service/Method"; a typo fails with a did-you-mean suggestion when
a known method is close enough.
Live channel
# config.yaml — 25 concurrent VUs, each holding its own channel per iteration
vus: 25
duration: 5m
# test.yaml
steps:
- name: open channel
use: std/grpc-connect@v1
with:
url: grpcs://api.example.com:443
reflection: true
metadata: { authorization: "Bearer ${{ vars.token }}" }
outputs: conn
- name: unary echo
use: std/grpc-call@v1
with:
id: "${{ conn.id }}"
method: "echo.v1.Echo/Unary"
payload: { message: "ping-${seq}" }
check:
duration_ms_lt: 250
- name: open bidi stream
use: std/grpc-stream-open@v1
with:
id: "${{ conn.id }}"
method: "echo.v1.Echo/Bidi"
outputs: stream
- name: send events
use: std/grpc-stream-send@v1
with:
id: "${{ stream.id }}"
payload: { message: "evt-${seq}" }
repeat: 5
interval_ms: 20
- name: await echoes
use: std/grpc-stream-recv@v1
with:
id: "${{ stream.id }}"
until_contains: "evt-5"
timeout: 5000
check:
messages_count_gte: 5
- name: close stream
use: std/grpc-stream-close@v1
with: { id: "${{ stream.id }}" }
Seven steps, each with a short alias:
std/grpc-connect@v1— opens the channel and loads the schema, returns{ id, connected, duration_ms }.std/grpc-call@v1— one unary call on the channel. A failed RPC fails the step but the channel stays usable: HTTP/2 channels recover, unlike a dead WebSocket.std/grpc@v1— the one-shot variant (connect → schema → call → close).std/grpc-stream-open@v1— starts a client-streaming, bidi, or server-streaming call, returns a stream id. For server-streaming the one request goes out at open; a server-side failure (UNIMPLEMENTED, auth, …) surfaces at the first recv/close, not at open.std/grpc-stream-send@v1— sendspayload(JSON,${…}tokens expand per send) orpayload_base64;repeat+interval_msstream N messages from one template.std/grpc-stream-recv@v1— reads until a stopping rule:until_contains,until_json, or plaincount.std/grpc-stream-close@v1— half-closes the request side and drains the server side to the final status.
Channel ids are minted per VU (grpc-1, grpc-2, …), stream ids likewise
(grpcs-1, …); both are valid only inside that VU's current iteration. A
channel never outlives its iteration: whatever a scenario leaves open is
dropped at iteration end (streams are cancelled — use grpc-stream-close
for a clean, status-checked shutdown), and grpc-connect inside a config's
before: setup is not useful: the setup context and its channels are gone
before VUs start.
Dynamic payloads
Requests take payload (JSON → dynamic protobuf message) or
payload_base64 (serialized protobuf bytes) — mutually exclusive. The JSON
mapping follows protobuf-JSON rules: field names accept both the proto name
and its camelCase json_name, 64-bit ints are strings, enums are names.
Responses appear in body (unary) and messages (streams) under the same
rules.
String leaves of payload may embed the same single-brace ${…} tokens as
WebSocket sends, expanded per call/send
(${seq} keeps counting per channel on unary calls, per stream on stream
sends). payload_base64 is decoded once and sent as-is — no token
expansion. Metadata values support ${{ … }} interpolation only.
Asserting responses
Stream receive/close steps expose a messages list; std/check@v1 asserts
over it with the any quantifier (at least one message matches):
check:
message_contains: "trade" # some message contains the substring
message_matches: { type: trade } # some message JSON-subset-matches
messages_count_gte: 5 # at least 5 messages arrived
For deterministic exchanges, address one message by index:
check: { on: got.messages.0, message_matches: { type: welcome } }.
Unary calls assert on the status code with expect_status (e.g.
expect_status: 5 passes when the server returns NOT_FOUND and fails on OK)
and on latency with check: { duration_ms_lt: … }; the JSON body can be
addressed field by field via outputs and on:.
Metrics
grpc_req_duration— unary call latency histogram (std/grpc@v1andstd/grpc-call@v1only). Streams deliberately do not feed it: their lifetimes span user steps.grpc_msg_rtt— application-level message RTT. On a successful unary call it equals the request duration; on a stream recv it is the send→match time, reported only when anuntil_*rule matched and agrpc-stream-sendpreceded it on the same stream.grpc_msgs_sent/grpc_msgs_received— message throughput counters, per call and per stream step.grpc_req_failed— RPCs that did not meetexpect_status. For streams,grpc-stream-closeis what turns the final status into this counter.
Unlike the WebSocket handshake, gRPC steps never feed http_req_duration /
http_req_failed — the grpc_* series are the whole story, and a failed
connect emits no metrics at all (no RPC was made).
Limits
Inbound messages are capped by max_recv_size (default 16 MiB); an
oversized message fails the call with RESOURCE_EXCEEDED. Binary (-bin)
metadata keys are not supported — metadata values are strings. Server
reflection must be explicitly enabled on the server for reflection: true
to work. Timeout values, repeat counts, and drain lengths have no built-in
caps.