Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

gRPC

gRPC is the framework’s proof that the model generalizes past JSON-over-HTTP: it’s the wire model with a protobuf format, plus a capability and a decoder that understand gRPC’s length-prefixed framing and its trailer-based status. An endpoint that speaks gRPC looks like every other endpoint — different associated types, same shape.

Scope: unary, client streaming, and server streaming. A unary call sends one message and reads one reply. A client-streaming call sends an ordered source of messages and reads one reply. A server-streaming call sends one message and reads a response of many. All three end in exactly one grpc-status, and what differs is how many frames each direction carries. Bidirectional streaming needs a session-shaped capability, as WebSocket has, and the two shipped adapters are half-duplex by construction; gRPC-Web is a separate protocol, with its trailer block inside the body, and needs its own adapter. capi_grpc’s README carries a conformance table that says this row by row, with the test behind each row.

Protobuf is a Codec format

Protobuf slots into the same contract/codec split as JSON and XML: capiw_ext_protobuf defines the Protobuf format marker, and capiw_prost provides the concrete codec:

let pb: Codec<Protobuf> = capiw_prost::codec();   // content type "application/protobuf"

A gRPC config stores a Codec<Protobuf> and exposes it (via the GrpcConfig trait’s protobuf_codec()), exactly as a JSON config exposes json_lib(). capiw_prost wraps prost’s low-level encoding primitives — not its Message derive — because the codec consumes the wire model’s erased event stream like any other backend.

Authoring the message types

You don’t run protoc. Message types are ordinary Rust structs decorated with #[proto_extension], which expands to the wire derives and lowers protobuf specifics (field numbers, syntax, packing) into the wire model’s extension bag:

/// `capi.test.v1.Number`: one value in the sequence `Accumulator/Sum` is streamed.
#[proto_extension]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Number {
    pub value: u64,
}

/// `capi.test.v1.Summary`: what one `Accumulator/Sum` call adds up to.
#[proto_extension]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Summary {
    pub count: u64,
    pub sum: u64,
}

Field numbers follow declaration order unless #[proto(id = N)] says otherwise. So a protobuf type reaches the codec through the same WireModel binding as a JSON type — the numbers and presence rules ride in the binding’s ext bag, which capiw_prost reads back to drive encode/decode. It’s the wire model all the way down.

The worked example throughout this chapter is grpc_test_capi_rs, the family’s gRPC reference client, and its service capi.test.v1.Accumulator, served by a real tonic peer its tests spawn. Sum takes a stream of Number and answers one Summary of their count and their wrapping sum; Count takes one Number and answers a stream of 1..=upto, one message each.

A unary endpoint

impl Endpoint for HealthCheck {
    type ApiConfig = GrpcTestConfig;
    type Output = HealthCheckResponse;
    type Error = GrpcError;
    type Decoder = GrpcDecoder;
    type Requires = Grpc;
    fn method(&self) -> Method { Method::POST }
    fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl {
        config.base_url().path([HEALTH_SERVICE, "Check"])   // /grpc.health.v1.Health/Check
    }
    fn content_type(&self, _config: &Self::ApiConfig, _context: &ApiContext) -> impl Into<HeaderValue> {
        capi_grpc::CONTENT_TYPE
    }
    fn body(self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
        let request = HealthCheckRequest { service: self.service };
        Frame::encode(config.protobuf_codec(), &request).map_err(BodyError::new)
    }
}

The gRPC-specific pieces: type Requires = Grpc (the capability), type Decoder = GrpcDecoder, capi_grpc::CONTENT_TYPE for the content type, and Frame::encode(codec, &request) to frame the one message. A Frame holds its bytes, so the request declares an exact Content-Length and stays replayable. Everything else is the ordinary endpoint contract. Like every protocol crate, capi_grpc sits behind no cargo feature of its own — it arrives as a crate dependency beside a protobuf codec; the transport adapters expose a grpc toggle for the lane that can carry it, and a client library conventionally gates its gRPC endpoints behind a grpc feature that pulls the crate in (the client capability table).

A client-streaming endpoint

The same five associated types. What changes is the body: instead of one framed message, the endpoint holds a MessageSource<M> and hands it to FramedBody.

pub struct Sum {
    pub numbers: MessageSource<Number>,
    // the reference client's own carries two more: an early-answer knob and a
    // RequestMetadata, both shown further down
}

impl Endpoint for Sum {
    type ApiConfig = GrpcTestConfig;
    type Output = Summary;
    type Error = GrpcError;
    type Decoder = GrpcDecoder;
    type Requires = Grpc;

    fn method(&self) -> Method { Method::POST }
    fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl {
        config.base_url().path([ACCUMULATOR_SERVICE, "Sum"])
    }
    fn content_type(&self, _config: &Self::ApiConfig, _context: &ApiContext) -> impl Into<HeaderValue> {
        capi_grpc::CONTENT_TYPE
    }
    fn body(self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
        Ok(FramedBody::new(config.protobuf_codec(), self.numbers))
    }
}

The two halves are separate because they are settled at different moments. The source is what the caller hands the endpoint; the codec belongs to the config and reaches the endpoint only in body(), which is where FramedBody::new pairs them. Keeping the source codec-free is also what lets one definition serve both lanes: MessageSource<M> is the same type sync or async, so the field needs no cfg twin.

Three ways to build one:

MessageSource::from_messages(numbers)                      // an iterator that cannot fail
MessageSource::from_fallible_messages(rows.map(read_one))  // an iterator of Result<M, E>
MessageSource::from_stream(incoming)                       // async lane: a Stream of Result<M, E>

from_messages and from_fallible_messages serve both lanes; from_stream is the asynchronous one (and browser wasm), and its stream is pinned on the heap for you, so a generator needs no Box::pin at the call site. A fallible source’s error is boxed as the cause of the failure the call reports — it stays downcastable to your own type all the way out.

The retryable shape

A source is turned once. Nothing can produce those bytes a second time, so re-sending a client-streaming call means asking the endpoint to describe itself afresh — and the framework says so at compile time rather than at runtime:

api.retry(RetrySpec::new()).query(Sum { numbers })   // does not compile

Every re-issuing runner — .retry(..), .retry_after(..), .refresh(..), .wait_until(..), the redirect-following runner — takes Endpt: Clone, because each attempt calls the endpoint again so body() runs afresh. A MessageSource holds an erased producer and is not Clone, so an endpoint holding one is not Clone either, and the pairing is refused where you wrote it.

To make such a call retryable, hold what the messages are made from and build the source inside body():

#[derive(Clone)]
pub struct SumOf {
    pub values: Vec<u64>,          // Clone: the recipe, not the source
}

impl Endpoint for SumOf {
    // …
    fn body(self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
        let numbers = MessageSource::from_messages(self.values.into_iter().map(|value| Number { value }));
        Ok(FramedBody::new(config.protobuf_codec(), numbers))
    }
}

Two seats below the runner can re-send a request without re-describing it, and both pay for it by pinning bytes: a middleware that takes a replay handle (no shipped member does on this lane — follow_redirects declares that it does not serve Grpc), and a signer with a non-zero retry budget, which holds every transmitted byte until the exchange settles. An endpoint that wants neither cost carries a signer with no budget.

A server-streaming endpoint

The same five associated types once more. The request goes back to being one framed message, exactly as a unary call’s is; what changes is the reading half. Output is a MessageIterator<M> and Decoder is GrpcStreamDecoder.

#[derive(Clone)]
pub struct Count {
    pub upto: u64,
    // the reference client's own carries three more: a stop-after knob, a pacing knob,
    // and a RequestMetadata
}

impl Endpoint for Count {
    type ApiConfig = GrpcTestConfig;
    type Output = MessageIterator<Number>;   // one item per message, in wire order
    type Error = GrpcError;
    type Decoder = GrpcStreamDecoder;
    type Requires = Grpc;

    fn method(&self) -> Method { Method::POST }
    fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl {
        config.base_url().path([ACCUMULATOR_SERVICE, "Count"])
    }
    fn content_type(&self, _config: &Self::ApiConfig, _context: &ApiContext) -> impl Into<HeaderValue> {
        capi_grpc::CONTENT_TYPE
    }
    fn body(self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
        Frame::encode(config.protobuf_codec(), &Number { value: self.upto })
            .and_then(|frame| frame.with_compression(&config.compression()))
            .map_err(BodyError::new)
    }
}

MessageIterator<M> is StreamIterator<M, ResponseHead> — the framework’s one stream handle, with its metadata slot closed over the response head. So it is consumed exactly like every other stream here: inherent next() / try_next() / try_collect() on both lanes, Iterator on the synchronous one and futures::Stream on the asynchronous one.

let mut numbers = api.query(Count::new(5))?;          // sync lane
while let Some(number) = numbers.try_next()? {
    println!("{}", number.value);                     // arrives as the server sends it
}
let mut numbers = api.query(Count::new(5)).await?;    // async lane
while let Some(number) = numbers.try_next().await? {
    println!("{}", number.value);
}

.query() returns when the head has been read, not when the stream has ended — a call that a peer paces yields its first message long before its last is sent. Items are Result<M, QueryError>, because a stream carrying its own transport can fail at any point; your GrpcError is reachable through QueryError::api_error::<GrpcError>().

The metadata slot is the head, trailer cell included, so both blocks read off the handle — the initial one at once, the trailing one after the end. The view is taken fresh each time, because draining needs the handle back:

let request_id = ResponseMetadata::of(numbers.metadata()).initial().ascii("x-request-id");
// … drain the stream …
let trailing = ResponseMetadata::of(numbers.metadata()).trailing();

The status is the stream’s end, not something beside it: an OK status ends it with None, and a non-OK status — after any number of messages — is the stream’s last item, carrying whatever rich status the peer sent. A failure is terminal too, so a stream is over the moment it hands you one. Count holds the value its request is made from rather than a source, so it is Clone and .retry(..) accepts it; what a runner can re-issue is a call refused at the head, since a stream that failed after its first item has no gRPC resumption shape to resume from.

The path one message takes

sequenceDiagram
    participant S as MessageSource
    participant B as FramedBody
    participant T as Transport
    participant P as Peer
    participant D as GrpcDecoder
    T->>B: read — the body is pulled, never drained
    B->>S: one turn
    S-->>B: the next message, exhausted, or failed
    B->>B: encode into the frame buffer, then write the prefix over it
    B-->>T: flag byte, big-endian length, payload
    T->>P: DATA on the HTTP/2 stream
    Note over S,P: repeats as the peer accepts bytes, then END_STREAM at exhaustion
    P-->>D: HEADERS, the reply frame, then the trailer block
    D->>D: unframe, decode through the codec, resolve grpc-status

Read it downward: the transport asks the body for bytes, the body turns the source once, the codec encodes that message straight into the frame buffer behind a placeholder prefix, and the prefix is written over the placeholder once the payload’s length is known. Nothing is produced before it is asked for — Endpoint::body performs no I/O at all, it only describes the request.

Coming back, GrpcDecoder reads the reply body as it arrives, unframes the single message, decodes it through the config’s Codec<Protobuf>, and resolves the status from the trailer block — or, for a trailers-only reply, from the response headers. It reads to EOF because that is when the trailers arrive, and it accumulates nothing past the first frame: a second one is refused from its prefix.

What it costs

  • One frame buffer. The codec encodes into it directly, so framing a message allocates nothing of its own. The buffer keeps the capacity of the largest frame so far, and nothing else: no message is retained once its bytes have been read, and both of the body’s endings release it.
  • No declared length. The encoded size of a message that has not been produced yet cannot be known without producing it, so the request carries no Content-Length and the message sequence is delimited by the end of the stream. That is what gRPC expects.
  • Fused endings. Exhaustion and failure are both final, and neither polls the source again. A failed source never becomes a successful end of request.
  • Compression costs a second buffer. A frame’s prefix carries the length of what is transmitted, so a compressed message must be finished before its prefix can be written: the message exists twice, encoded in a scratch buffer and coded in the frame buffer. Each message additionally pays for a fresh codec — per message, because gRPC finishes every message’s compressed stream on its own. Measured over 500-byte messages, an uncompressed message costs about 2 allocations and 2.5 KiB, and a gzip-compressed one about 10 allocations and 328 KiB, of which 319 KiB is the encoder’s match tables. None of it accumulates.

Nothing here imposes a size ceiling. Three optional maximums exist, each measuring a different quantity — the encoded message about to be sent (FramedBody::with_max_message_size), the length an incoming frame’s prefix declares, which for a compressed frame is its compressed length (GrpcConfig::max_message_size), and what a compressed member expands to (CompressionPolicy::with_max_decompressed_size) — and every one of them is off by default. Your peer is another matter: tonic and grpc-go refuse a received message over 4 MiB by default, with RESOURCE_EXHAUSTED.

The call lifecycle

A call has one sending direction and exactly one final status. The exchange is half-duplex: the response is returned once the request reaches EOF or the server stops the upload. Everything below but the last paragraph governs a request of one message and a request of many alike — the difference is how many frames the request body carries, not how the call ends. A response of many messages is the reading half’s own.

The sending direction ends in one of three ways. Exhaustion — the source reports no further messages and the request ends there; a source of zero messages is a legal call, not a malformed one. Terminal failure — the source, or the encoding of a message it yielded, fails; that failure is the call’s error and never becomes a successful EOF. Cancellation — the request’s deadline or budget ends the exchange and the adapter resets the stream; the send reports it as its own SendError arm (TimedOut, Stalled, Cancelled), distinct from an adapter failure.

The server may answer before the request finishes, and a service that validates its first message often does. Whatever follows that answer, a complete response is the call’s result: the peer may reset the stream with NO_ERROR, reset it with CANCEL, or not reset it at all and let the client upload to exhaustion. In the first two the upload ends where it is and the unsent remainder is not an error; in the third the answer you already hold is still the answer. A reset arriving before the response is complete is a different thing entirely — a transport failure carrying the peer’s HTTP/2 error code, mapped to the status the protocol assigns it.

Deadlines are stated to the server. Both adapters derive grpc-timeout from the request’s deadline and send it, so a long upload can be ended by the server as well as by the client; an application-set grpc-timeout is refused rather than merged. In practice the adapter’s own budget answers first, at the deadline on the asynchronous lanes.

A producer failure reaches you whole. It is not an Endpoint::Error — that type describes what a server said, and nothing was said here. It travels as the request body’s failure: a MessageError naming the zero-based index of the message being produced and carrying your own error under it, reachable by walking Error::source() and downcasting. (One caveat: hyper drops a request-body error on HTTP/2, so the reqwest lane reports the call as an opaque transport failure with nothing of the producer in it. The native client carries it.)

One read, one message — on the synchronous lane. A synchronous read turns the source at most once, which is what transmitting a stream means: a source that takes a second per message sends them a second apart rather than going quiet until the transport’s buffer fills. The cost is that an early answer is acted on at the next message boundary, and that nothing can interrupt a turn of the source — a source that blocks waiting for its next message is unobservable until it returns. The asynchronous lane frames whatever is ready and stops at the source’s own Poll::Pending, so it sees an early answer at once. A source whose individual waits are long belongs on the async lane.

A response of many messages. Before any message, the head decides: a trailers-only status, a non-200 from an intermediary, a 200 that is not gRPC, a grpc-encoding the call did not accept, or a head that cannot carry trailers at all. Each of those is the call’s error from .query() rather than an item — a stream that can never be given a status refuses before it yields, instead of discovering that at EOF. Past the head, a trailers-only OK response is an empty stream: a server with nothing to send may answer with the status alone, and that is the one place the two decoders differ, since for a unary call the same response is a missing message. The status is the end: OK ends the stream with None, and a non-OK status is its last item. So is a failure — bad framing, a limit, a member the codec refuses, a message that does not decode, a read error — after which nothing more is read and the body is released with it. Dropping the handle cancels the call: the body drops, the adapter resets the stream, the peer sees CANCEL, and no status arrives; the drop itself is not an error. The deadline governs the whole stream rather than each message, so either the adapter’s budget ends the read or the server ends the call with DEADLINE_EXCEEDED, whichever lands first. And the request stays one message: pairing this decoder with a FramedBody is bidirectional on the wire, the two adapters do different things with such a call, and no evidence certifies either — the ledger carries it as an unsupported row rather than a refusal.

Per-message compression

gRPC compresses messages, not bodies. Each message is compressed on its own, finished on its own, and carried in a frame whose length prefix counts the compressed bytes with the flag byte set to 0x01. A whole-body Content-Encoding would compress the framing between messages, so the request body stays identity-coded and the adapters add no content-encoding of their own.

The algorithms live in their own crates: capie_gzip for gzip, capie_miniz_oxide for the zlib-wrapped DEFLATE that gRPC calls deflate. capi_grpc owns the flag, the token vocabulary, and the negotiation, and drives whatever codec it is handed:

impl GrpcTestConfig {
    pub fn gzip() -> MessageCoding {
        MessageCoding::gzip(|| GzipEncoder::new(capie_gzip::Level::DEFAULT), GzipDecoder::new)
    }
    pub fn deflate() -> MessageCoding {
        MessageCoding::deflate(|| ZlibEncoder::new(capie_miniz_oxide::Level::DEFAULT), ZlibDecoder::new)
    }
}

The two factories rather than two codecs is the protocol’s doing: a fresh instance per message is what “independent compression context” means.

Compression is the config’s, and an endpoint follows it. A CompressionPolicy names what the call sends and what it can decode, independently:

CompressionPolicy::none()                          // the default: nothing, either way
CompressionPolicy::using(GrpcTestConfig::gzip())   // send gzip, accept gzip
CompressionPolicy::sending(coding)                 // compress out, take plain replies
CompressionPolicy::accepting(coding)               // send plain, decode compressed replies
    .and_accepting(other)                          // in preference order
    .with_bypass_below(512)                        // short messages go out as they are
    .with_max_decompressed_size(8 * 1024 * 1024)   // refused at the bound, not after it

The config states it once, GrpcConfig::grpc_headers() stamps grpc-encoding and grpc-accept-encoding from it, and an endpoint’s headers() is one call over that — so the negotiation is never hand-written at an endpoint:

fn headers(&self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<HeaderMap, HttpHeaderError> {
    let mut headers = config.grpc_headers();
    self.metadata.stamp(&mut headers);
    Ok(headers)
}

FramedBody::new(..).with_compression(config.compression()) in body() is the sending half; the receiving half reaches the decoder through the config. There are no defaults: a call that names nothing compresses nothing, sends neither header, and refuses a reply frame that claims compression — which is exactly what the protocol says a peer that was told nothing may not send. A peer that was not configured to accept your encoding answers UNIMPLEMENTED with its own grpc-accept-encoding attached, which you can read (see below).

Metadata and rich status

Metadata is the key/value pairs a call carries beside its messages. Outgoing pairs are a RequestMetadata, built once and checked as it is built, so the endpoint holds a checked value and headers() stays infallible:

let metadata = RequestMetadata::new()
    .ascii("x-tenant", "acme")?
    .binary("x-trace-bin", trace_id.as_bytes())?;   // base64, un-padded, under a -bin name

Every name is held to the protocol’s Header-Name production (lowercase), every ASCII value to printable ASCII, and every grpc- name is refused — that prefix is the protocol’s, and grpc-encoding, grpc-accept-encoding and grpc-timeout are placed for you by the config and the adapter.

Incoming metadata arrives in two blocks — the response headers, and the trailer block the status rides in — and ResponseMetadata reads both through one view, off a ResponseHead:

let (head, summary) = api.with_headers().query(Sum { numbers }).await?;
let metadata = ResponseMetadata::of(&head);
let request_id = metadata.initial().ascii("x-request-id");
let elapsed = metadata.trailing().and_then(|block| block.ascii("x-elapsed-ms"));

trailing() is None until the body reaches EOF and the head’s shared trailer cell fills — which for a decoded gRPC call has already happened, because that is where the status lives. A repeated name is an ordered list and ascii() answers with the first of it; a -bin value decodes from base64 padded or un-padded, and is split on , first so a repeated name and the joined form an intermediary may make of it read the same. The reserved grpc- pairs stay reachable rather than hidden — custom() and reserved() separate them.

A failed call is read the same way. with_headers() keeps its head clone on the error arm, so QueryError::response_head() carries a refusal’s head, trailer cell included — which is how you read the grpc-accept-encoding a peer attached to its UNIMPLEMENTED, or the trailing metadata a rejection carries, without dropping to with_raw_response().

A failing call’s status is GrpcError::Status(GrpcStatus), carrying the typed GrpcCode, the percent-decoded grpc-message, and — when the peer sent grpc-status-details-bin — the google.rpc.Status it describes. A detail stays raw, type_url plus bytes, and AnyDetail::decode turns one into whatever message its type names through the config’s codec; the standard google.rpc detail messages are not modeled here, because each is an Any payload like any other and a service that sends one describes it with #[proto_extension]. A details code that contradicts grpc-status is a typed error keeping both, not a silently preferred one.

Recording a gRPC call

gRPC calls record and replay through the same fixtures every other endpoint uses; the recorder needs a gRPC-capable live client attached, which is one step (.with_grpc(client), or .with_bundled_grpc()).

A streaming request body needs one decision, because the two ways of capturing one each cost something:

ReplayConfig::default().capture_streaming(StreamingCapture::Spooled)
  • Refuse (the default) fails the send rather than record a call it cannot represent.
  • Buffered drains the source, then sends the bytes. Right for a small source already in memory; wrong for anything else, because every message is produced before the first one leaves and the wire sees a content-length. The fixture records that it was buffered, so it says plainly that the recorded run was not the streaming run.
  • Spooled lets the body stream and captures the bytes as the transport reads them, and records how the upload ended — complete, stopped after so many bytes and whole frames, or failed at a message index. Replay then turns the source exactly that far, so a recorded early answer never asks for the messages the live peer did not.

A streaming response is drained before you see it, and that is by design: a fixture is an artifact a test will replay, so the exchange is fully known before anything is written, and the recorder reads the whole response ahead of the caller’s first item. Every frame the peer sent is in the fixture, and replay yields them one at a time as a live stream does; what a fixture does not keep is the timing. When the timing is the thing you are watching, reach for the diagnostic capture instead (ReplayClient::diagnostic_with_client_and_backend), which tees the body rather than draining it — the stream stays a stream, and the pieces it arrived in are written to a timing sidecar.

Bodies compare per logical message rather than byte-for-byte over the whole body, so read sizes and HTTP/2 DATA boundaries play no part. A compressed frame is expanded first, when the call’s coding is registered as a FrameCoding: ReplayConfig::with_grpc_coding(GrpcTestConfig::gzip()). Without it a compressed body is refused rather than compared, because coded bytes are an encoder’s output and not the call’s message.

A secret in a request message goes through the redacted twin. Nothing masks a recorded frame in place — protobuf names no field on the wire, and re-encoding a message would change lengths the frame prefix and the length-delimited field both count — so api.query_redacted(endpoint) is the route: the fixture is a twin built from a redacted clone of the endpoint, and every #[wire(sensitive)] field was a placeholder before it became a message. Replay compares that twin against the twin the next run builds.

It asks the endpoint to be Clone + ApiRedact, which is the recordable shape: hold the values the messages are made from and build the MessageSource in body(), exactly as the retryable shape above does. Two gaps come with that, and both are deliberate:

  • An endpoint that owns a one-shot source is not Clone, so there is no second description to redact — and such a call has no deterministic replay either.
  • A gRPC reply is recorded exactly as the peer sent it. A service that mints a credential writes it into the fixture in the clear.

What the recorder refuses rather than half-doing is an ask it cannot meet: a body-key class over a gRPC body that crossed the wire, and a value registered with ReplayConfig::with_sensitive that appears in any gRPC body, a twin’s included — a registered value inside a twin is one the twin did not redact.

What a fixture cannot reproduce is anything that is a property of a live connection: backpressure and flow-control credit, a RST_STREAM and the code it carried, a deadline expiring mid-upload, the interleaving of an answer with a read. Those belong to a transport suite driving a real peer — grpc_test_capi_rs/tests/wire.rs scripts an HTTP/2 peer for exactly this — and a fixture is not evidence about them.

Where the evidence is

grpc_test_capi_rs is the reference client, and its six suites are what the conformance table in capi_grpc’s README cites row by row: tests/streaming.rs drives Sum against a real tonic peer on every lane and adapter, tests/server_streaming.rs drives Count the same way, tests/wire.rs drives both against a scripted HTTP/2 peer, tests/compression.rs against both under a policy, tests/metadata.rs for metadata and rich status, and tests/recording.rs through the test framework’s recorder. That table also says which build each row was measured in — a configuration that only compiles says so there.

Next: switching outputs — the unifying idea behind stream/deferred/ paged variants of one endpoint.