Unary gRPC on Reactor Netty: Event Loop Serialization, Trailers, and Cancellation
With protocol values and message framing complete, Stage 2 delivered the first end-to-end call: plaintext h2c unary RPC. This is already on main, and Stage 3 and Stage 4 subsequently completed all four RPC cardinalities on the same transport primitive.
Method Descriptor Is Where Protocol Meets Types
A method requires a precise service name, method name, cardinality, and request/response marshallers:
1 | var echo = new GrpcMethod<>( |
The generated path must be:
1 | /testing.EchoService/Echo |
The service registry matches by exact full path. An unknown path returns UNIMPLEMENTED; registering the same path twice fails immediately when building the service definition.
Server Validates Protocol Before Subscribing to Business Logic
ReactorGrpcServer uses Reactor Netty h2c:
1 | DisposableServer bound = HttpServer.create() |
Incoming requests are validated in order:
- HTTP method must be POST;
- content-type must be
application/grpcorapplication/grpc+...; temust declare trailers;- path must exist;
- currently only unary cardinality is allowed;
- metadata and message size must not exceed limits.
Only after validation passes does it create a GrpcCallContext and subscribe to the request body, preventing invalid requests from entering the business handler.
HTTP 200 Does Not Mean RPC Success
The server writes a compatible content-type first; the final status comes from trailing headers:
1 | response.status(200) |
A GrpcException thrown by the application preserves an explicit status and trailing metadata. Ordinary application exceptions map to UNKNOWN; protocol violations map to INTERNAL. Fatal JVM errors should not be wrapped as ordinary business status.
After receiving a response, the client must read the final grpc-status. HTTP 200 without a status is INTERNAL; a non-gRPC HTTP 404 maps to UNIMPLEMENTED per the standard fallback, and 503 maps to UNAVAILABLE.
Metadata Must Preserve Order and Allow Duplicates
gRPC metadata is not a simple Map<String, String>. The same key can repeat, order must be preserved, and the -bin suffix indicates binary values encoded as no-padding Base64 on the wire.
GrpcMetadata therefore stores an immutable entry list internally:
1 | GrpcMetadata.builder() |
User metadata cannot overwrite reserved fields like content-type, te, grpc-status, grpc-timeout, and HTTP/2 pseudo headers. The total encoded size is also limited on read to prevent peers from consuming unbounded memory through headers.
Deadline Wire Values Cannot Be Simply Truncated
grpc-timeout consists of up to eight digits and a unit, such as 100m or 2S. Formatting must use ceiling division to ensure the wire deadline is never shorter than what the caller requested:
1 | BigInteger amount = ceilDivide(nanos, unit.nanos); |
In the Stage 2 commit covered by this post, the client only writes the timeout header and sets the Reactor Netty response timeout slightly longer to distinguish gRPC deadlines from underlying connection waits. Merely being able to parse the header does not constitute full support; Stage 5 later adds client and server Reactor deadline timers, handler cancellation, and GrpcCallContext propagation. The current implementation still does not automatically derive shorter deadlines for nested calls on behalf of business code.
Unary Cardinality Is Enforced with single()
The client uses requests.single() for unary requests, and responses likewise require exactly one value. Empty requests, duplicate requests, empty responses, and duplicate responses are not silent defaults — they are explicit errors.
Current transport test coverage:
- unary message and ASCII metadata round-trip;
- declared error and trailing metadata;
- unknown method;
- empty/duplicate unary data;
- non-gRPC HTTP response and missing status;
- client cancellation propagation to server publisher;
- connection failure mapping to
UNAVAILABLE.
The unary vertical slice tests continue serving as regression baselines for streaming transport. Server, client, and bidirectional streaming have been completed in subsequent commits; TLS, GOAWAY, keepalive, compression, deadline, connection pool, and transport diagnostics were also completed in Stage 5 and merged to main.
Full Lifecycle of a Client Request
A ReactorGrpcClient unary call ultimately maps to a complete HTTP/2 stream interaction. Here is the full path from user call to network response:
1 | client.unary(method, Mono.just(request)) |
Key design: the entire pipeline is lazy — no HTTP/2 connection is initiated until the Mono is subscribed to. This differs from grpc-java’s ClientCall.start() which triggers connection immediately.
Why Serialize on the Event Loop
Each Reactor Netty HTTP/2 connection is bound to a single Netty event loop thread. If marshalling happens on the user thread, the serialized ByteBuf must be transferred across threads to the event loop, incurring synchronization overhead.
onEventLoop() defers serialization to the connection’s event loop:
1 | private <T> Flux<T> onEventLoop(NettyOutbound outbound, Flux<T> source) { |
Benefits:
- Serialization and network writes happen on the same thread, lock-free;
- The ByteBuf allocator is consistent with the connection, reducing cross-arena allocation;
concatMap(..., 1)controls prefetch, ensuring messages are sent in strict order.
Error Mapping: From HTTP/2 Exceptions to gRPC Status
Network-layer errors cannot be exposed directly to users — they need to be mapped to gRPC semantics:
| Network Exception | gRPC Status | Meaning |
|---|---|---|
Http2Exception.StreamException(REFUSED_STREAM) |
UNAVAILABLE | Server refused this stream, safe to retry |
| GOAWAY frame | UNAVAILABLE | Server is shutting down gracefully |
| Connection closed / reset | UNAVAILABLE | Network disconnected |
| HTTP 404 (non-gRPC response) | UNIMPLEMENTED | Path doesn’t exist (may have passed through a non-gRPC proxy) |
| HTTP 503 | UNAVAILABLE | Service temporarily unavailable |
Missing grpc-status |
INTERNAL | Protocol violation |
content-type mismatch |
INTERNAL | Response is not gRPC |
This mapping follows the HTTP status code to gRPC status conversion table defined in the gRPC protocol spec. UNAVAILABLE means the client can retry; INTERNAL and UNIMPLEMENTED are generally not worth retrying.
How Client Cancellation Propagates to Server
Reactor’s dispose() needs to be mapped to HTTP/2’s RST_STREAM, letting the server know this call is no longer needed:
1 | client: subscription.dispose() |
The server side listens for stream close events through a connection handler and converts them into a peerCancellation signal. The business handler uses takeUntilOther(peerCancellation) to subscribe to this signal, ensuring the server stops unnecessary computation after client cancellation.
This mechanism naturally aligns Reactor’s backpressure semantics (cancel = I no longer need data) with gRPC’s cancellation semantics (RST_STREAM = call aborted).
Interoperability Verification with grpc-java
Stage 2’s exit criteria are not “calling ourselves successfully” — it must interoperate bidirectionally with grpc-java. UnaryInteroperabilityTest verifies both directions:
Reactor client -> grpc-java server:
1 |
|
grpc-java client -> Reactor server:
1 |
|
Both directions verify: normal responses, error status codes, percent-encoding round-trip of error messages, and custom trailing metadata propagation. Only when grpc-java can correctly parse Reactor server’s response can we prove the wire protocol implementation is compatible.
Stage 2 Exit Criteria
./gradlew clean spotlessCheck test --no-daemonpasses in full- Reactor client <-> grpc-java server bidirectional unary calls succeed
- grpc-java client <-> Reactor server bidirectional unary calls succeed
- Error status, message, and trailing metadata propagate correctly
- Client cancellation propagates to server via RST_STREAM
- Connection failure maps to UNAVAILABLE
- Unknown path maps to UNIMPLEMENTED
- All Stage 0 and Stage 1 tests remain green
The next post enters Stage 3 and Stage 4: implementing server streaming, client streaming, and bidirectional streaming on the same transport primitive, facing new challenges of multi-message backpressure coordination, bounded inbound buffering, and resource cleanup on premature stream termination.
- Title: Unary gRPC on Reactor Netty: Event Loop Serialization, Trailers, and Cancellation
- Author: Elvis
- Created at : 2026-06-03 15:50:00
- Updated at : 2026-08-08 10:00:00
- Link: https://qianwj.github.io/en/2026/06/03/grpc-reactor-series-3/
- License: This work is licensed under CC BY-NC-SA 4.0.