gRPC¶
A gRPC method is a route. app.post("/package.Service/Method", handler) registers it, the handler reads the message with c.body() and answers with c.send, and a listener that speaks gRPC turns each call into that request and the answer back into what the client expects. Middleware, fail functions, deadlines, counters and the log see a call as the request it became.
It is for the callers you do not choose: a service whose contract is a .proto file, an OpenTelemetry Collector exporting OTLP, a Kubernetes plugin, an Envoy filter. Unary calls only, and it has to be built in (ADR 220).
Turning it on¶
In your build.zig, ask the dependency for it:
const nilo = b.dependency("nilo", .{ .target = target, .optimize = optimize, .grpc = true });
Then give it a listener of its own, beside the one that serves HTTP/1.1:
fn serve(app: *nilo.App) !void {
try app.post("/demo.Echo/Say", say);
try app.listen(.{
.port = 8080,
.also = &.{.{ .port = 50051, .grpc = true }},
});
}
fn say(c: *nilo.Ctx) !void {
const message = try c.body();
try c.send(200, "application/grpc", message.view());
}
Port 50051 speaks HTTP/2 with prior knowledge (h2c), which is what every gRPC client sends to a plain address. Port 8080 is HTTP/1.1 exactly as before, and a request there to /demo.Echo/Say is an ordinary POST. A server that should speak nothing but gRPC puts .grpc = true on listen()'s own options instead.
A build that did not pass .grpc = true refuses the listener at listen() with a sentence naming the flag, and contains none of the HTTP/2 code: a program that never asks pays 8 to 112 bytes of binary.
A method¶
The path is the one in the .proto: package, service and method, /helloworld.Greeter/SayHello. c.body() is the message with gRPC's five-byte prefix taken off, and gunzipped if the client sent grpc-encoding: gzip, which the Collector does on every call. c.send(200, "application/grpc", bytes) is the answer, framed on the way out.
The codec is yours. zig-protobuf generates a type for each message with an encode and a decode, and those are what a method calls on c.body() and before c.send. nilo reads and writes bytes and never looks inside them.
A call's metadata arrives as request headers, so c.header("x-tenant") reads it, and a header the route sets with c.setHeader goes back as metadata.
When a call fails¶
A route that fails the ordinary way is answered with the gRPC status its HTTP status means, and the failure's message as grpc-message:
| the route failed with | the client sees |
|---|---|
| 400, 415, 422 | INVALID_ARGUMENT (3) |
| 401 | UNAUTHENTICATED (16) |
| 403 | PERMISSION_DENIED (7) |
| 404 | NOT_FOUND (5) |
| 409 | ABORTED (10) |
| 412, any other 4xx | FAILED_PRECONDITION (9) |
| 413, 429 | RESOURCE_EXHAUSTED (8) |
| 503 | UNAVAILABLE (14) |
| 500 | INTERNAL (13) |
So return fail.notFound("no order {d}", .{id}) is NOT_FOUND with that sentence, and nothing about the handler knows gRPC is involved. A code with no status of its own, ALREADY_EXISTS say, is a grpc-status header on a 200: try c.setHeader("grpc-status", "6").
A path no route answers is UNIMPLEMENTED, and a message larger than max_body is RESOURCE_EXHAUSTED.
Deadlines¶
A client's grpc-timeout is the request's deadline, the same one nilo.deadline(ms) gives a route (deadlines): every wait nilo owns is cut short by it, and c.overdue() answers for a loop of your own. A route that fails once the client's time is up is answered DEADLINE_EXCEEDED whatever it failed with, because that is what happened as far as the client can tell. limits.request_deadline_ms does not lengthen a deadline a call brought; a route's own nilo.deadline replaces it.
Over TLS¶
A listener with both .tls and .grpc offers h2 by ALPN and nothing else, in a build that also passed .tls = true (TLS without a proxy). A client that offers only http/1.1 fails the handshake there, and a TLS listener without .grpc still offers only http/1.1.
What it does not do¶
- Streaming calls. One message in, one out. A call that sends a second message is answered
INTERNAL. - HTTP/2 for anything but gRPC. A browser, or
curl --http2to a plain route, still reaches nilo as HTTP/1.1; a proxy in front is still the answer for HTTP/2 there. - Both on one port. A connection to a gRPC listener that does not open with HTTP/2's preface gets a 505, and an HTTP/1.1 listener refuses the preface as a request line it cannot read.
- Compressed answers. A client's gzip is read; the answer goes back uncompressed.
What it costs¶
An idle gRPC connection costs under a page more than an HTTP/1.1 one, about 5.8 KB, and the HTTP/1.1 listeners of the same build cost what they did without it. A call in flight is a fiber, 4,547 bytes plus the stack the route touches, and a connection holds at most 100 at once, which it tells the client when it connects. From the second call on a connection, a call allocates nothing on the heap.
On four cores a unary call runs at about 770,000 a second against grpc-go's 590,000 and tonic's 900,000, and the slowest call is slower than either's. The difference is where the call's fiber is scheduled rather than HTTP/2, and it is written up with the rest of the numbers in bench/result/http.md.