gRPC
Anole serves gRPC services generated from .proto files on the same router
as your REST handlers. One registration answers four wire protocols, told
apart by the request’s content type:
| Protocol | Content type | Transport |
|---|---|---|
| gRPC | application/grpc, application/grpc+proto |
HTTP/2 (h2c or TLS) or HTTP/3 |
| gRPC-Web | application/grpc-web, application/grpc-web-text (+proto) |
HTTP/1.1, 2 or 3 |
| Connect unary | application/proto, application/json |
HTTP/1.1, 2 or 3 |
| Connect streaming | application/connect+proto, application/connect+json |
HTTP/1.1, 2 or 3 |
Methods annotated with google.api.http also get REST routes of their own,
JSON in and out (JSON transcoding).
A gRPC request over HTTP/1.1 gets 505 HTTP Version Not Supported, and an
unknown content type gets 415. gRPC needs -Dgrpc and -Dhttp2, both on in
the default full profile. HTTP/3 needs -Dhttp3 and a TLS listener with
HTTP/3 (on by default with listenTls); see HTTP/3.
examples/grpc is a runnable Greeter with the health service:
zig build run-grpc # h2c on 127.0.0.1:8083, TLS on 127.0.0.1:8483zig build run-grpc -- 9000 9443 # other portsGenerate code
Section titled “Generate code”You install nothing. Anole pins protoc and zig-protobuf’s protoc-gen-zig,
and zig build downloads and builds them. The short way is addApp, which
makes the executable and generates the code for each .proto as part of
zig build, into the cache, whenever the file changes:
const std = @import("std");
pub fn build(b: *std.Build) void { _ = @import("anole").addApp(b, .{ .name = "greeter", .root_source_file = b.path("src/main.zig"), .protos = &.{.{ .name = "greeter_pb", .file = b.path("proto/greeter.proto") }}, });}The program imports each proto by its name (@import("greeter_pb")).
protoc looks for imports in the file’s directory, include_directories and
Anole’s google/api protos. The generated module is the file for the proto’s
package (read from the file; set .package for a file outside the
project). addApp also installs the executable, adds zig build run, takes
target, optimize and Anole’s profile, and returns the executable and
the Anole dependency (.exe, .anole). For an executable of your own,
addProtoModule(b, anole_dep, proto) returns the module alone. Reuse
addApp’s .anole rather than calling b.dependency("anole", ...) again
with other arguments: two instances of Anole in one build collide.
To commit the generated code instead, give a step the .proto files with
addProtoGen, and give the generated files the protobuf module that
Anole exports:
const anole_build = @import("anole");
pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); const anole_dep = b.dependency("anole", .{ .target = target, .optimize = optimize });
const gen = b.step("gen-proto", "Generate Zig from the .proto files"); anole_build.addProtoGen(gen, anole_dep, .{ .source_files = &.{b.path("proto/greeter.proto")}, .include_directories = &.{b.path("proto")}, // protoc's -I .destination_directory = b.path("src/gen"), });
const greeter_pb = b.createModule(.{ .root_source_file = b.path("src/gen/greeter.pb.zig"), .target = target, .optimize = optimize, .imports = &.{.{ .name = "protobuf", .module = anole_dep.module("protobuf") }}, }); // ... then add `anole_dep.module("anole")` and `greeter_pb` as imports // of your executable's root module.}Run zig build gen-proto after you change a .proto file, and commit the
generated .pb.zig files like any other source. addProtoGen also puts
Anole’s copies of google/api/annotations.proto and google/api/http.proto
on protoc’s include path, so a file can import them for
JSON transcoding without vendoring them; the code for
their packages is generated beside yours (google/api.pb.zig, and
google/protobuf.pb.zig for descriptor.proto). tests/consumer is a
complete project with both.
For each service, the generated file has a function that returns its method
table, for example pb.Greeter(UserData, ErrorSet). Anole instantiates it with
anole.grpc.Call as the user data.
Serve a service
Section titled “Serve a service”const std = @import("std");const anole = @import("anole");const pb = @import("greeter_pb");
pub const std_options = anole.std_options; // optional: quieter Debug builds
const Call = anole.grpc.Call;
fn sayHello(call: *Call, req: pb.HelloRequest) anyerror!pb.HelloReply { if (req.name.len == 0) return call.fail(.invalid_argument, "name must not be empty"); return .{ .message = try std.fmt.allocPrint(call.arena, "hello, {s}", .{req.name}) };}
pub fn main(init: std.process.Init) !void { var app = try anole.App.init(init.gpa, .{}); defer app.deinit(); app.grpc(pb.Greeter, .{ .SayHello = sayHello }); // HTTP/1.1 and h2c; listenTls(":8483", .dev_self_signed) for ALPN h2. try app.listen(":8083");}app.grpc adds one POST route per method, /<package>.<Service>/<Method>,
here /greeter.Greeter/SayHello, plus Connect GET for methods marked
NO_SIDE_EFFECTS and the REST routes of JSON transcoding.
Every method of the service needs a handler; a missing one is a compile
error. Middleware added with app.use runs for these routes too.
The handlers can come three ways:
- a struct literal, as above;
- a type that declares them by method name:
app.grpc(pb.Greeter, Greeter); - a pointer to a value whose methods take it first,
fn SayHello(self: *Greeter, call: *Call, req: pb.HelloRequest) !pb.HelloReply:app.grpc(pb.Greeter, &greeter). The value must outlive the server.
With application state, use anole.Server(App) and reach the state from a
handler with call.state(App).
app.grpcWith(Service, impl, options) takes options for one registration:
app.grpcWith(pb.Greeter, .{ .SayHello = sayHello }, .{ .max_message_size = 64 * 1024, // default anole.grpc.default_max_message_size, 4 MiB .data = &greeter_state, // `call.data` in each handler of this registration .transcode = false, // gRPC routes only .response_compression = .accepted, // default .mirror; see Compression .no_wait = true, // the unary handlers never wait; see below .max_received_bytes = 16 * 1024 * 1024, // default 64 MiB per call .read_timeout = .fromSeconds(10), // default 30 s per request message .max_call_duration = .fromSeconds(600), // default none});app.grpc is anole.grpc.register(Ctx, app.router, pb.Greeter(Call, anyerror), impl) (and registerWith), which work on any anole.http router.
max_message_size bounds each request message after decompression; a larger
one fails the call with RESOURCE_EXHAUSTED. data is for handlers that serve
more than one registration, such as the same service on two servers.
The other limits bound what one client can hold on the server:
max_received_bytes(64 MiB) is what one call may receive in all, each message counted at its size plus 64 bytes. Decoded request messages live in the call’s arena until the call ends, so this is what bounds a long client-streaming or bidirectional call; past it the call fails with RESOURCE_EXHAUSTED. An upload of a large file in chunks needs it raised, or the upload split over several calls.read_timeout(30 s) is how long a request message may take to arrive: from the start of a unary or server-streaming call, and from a message’s first byte in a client-streaming or bidirectional call, whose client may pause between messages as long as the call lasts. Past it the call fails with DEADLINE_EXCEEDED. It also bounds what the server does for the request once the handler is done (sending the response, reading what is left of the body). Null leaves reads to the call’s deadline alone.max_call_duration(none) caps every call: the client’s deadline, and the deadline of a call that sent none. Without it, a call whose client sent no deadline runs as long as its handler does, as on other gRPC servers, so a long-lived stream ends only when the client or the handler ends it. Set it to bound those.
A decoded message can take much more memory than its bytes on the wire (a
repeated field of empty messages costs a whole struct per two bytes, a packed
field eight bytes per byte), so a registration whose messages carry large
repeated fields is worth a max_message_size well below the 4 MiB default.
app.grpc registers behind the global middleware only. To put a service
behind more, register it through a group: app.group("/v1", .{auth}).grpc(...)
or app.with(.{auth}).grpc(...). The group’s middleware runs for the
service’s gRPC calls and its transcoded REST routes; its prefix does not
apply (both paths come from the proto: /pkg.Users/Get stays where it is).
What a gRPC client sees when middleware answers first (a JWT 401, a rate
limit 429) is the HTTP status, which gRPC and Connect clients map by the
spec’s table (401 UNAUTHENTICATED, 403 PERMISSION_DENIED, 429 UNAVAILABLE,
404 UNIMPLEMENTED).
A call’s strings are copied: call.fail(code, message), call.failFmt(code, fmt, args), call.addHeader and call.addTrailer keep their own copies, so a
message formatted into a stack buffer is safe. addHeader and addTrailer
refuse the protocols’ own keys (grpc-* but grpc-status-details-bin and
grpc-retry-pushback-ms in trailers, connect-*, trailer-*, content-type, te and the transport’s
headers) with error.InvalidMetadata; addHeader adds a value, so a repeated
key keeps every value.
no_wait is anole.noWait (Routing, requests and responses) for the
registration’s unary methods: their handlers answer from the request and
memory alone, with no database, cache or other service and no sleeping. A
call that arrives whole in one read (HEADERS and its message, as clients
send a small unary call) then runs right where the HTTP/2 connection reads
it, without a task, which nearly doubles small unary calls per core. Such a
call’s grpc-timeout is not enforced, since it has nothing to wait for.
Streaming methods keep their tasks either way.
Handler shapes
Section titled “Handler shapes”The method’s kind comes from the generated signature. Requests and responses
that stream are std.Io.Queues:
// Unaryfn unary(call: *Call, req: Req) anyerror!Resp
// Server streaming: put responses on `out`; returning ends the call.fn serverStream(call: *Call, req: Req, out: *std.Io.Queue(Resp)) anyerror!void
// Client streaming: take requests until the queue is closed.fn clientStream(call: *Call, in: *std.Io.Queue(Req)) anyerror!Resp
// Bidirectional streamingfn bidi(call: *Call, in: *std.Io.Queue(Req), out: *std.Io.Queue(Resp)) anyerror!voidRead a request queue until it reports error.Closed, which means the client
has finished sending:
fn bidi(call: *Call, in: *std.Io.Queue(pb.Note), out: *std.Io.Queue(pb.Note)) anyerror!void { while (true) { const note = in.getOne(call.io) catch |err| switch (err) { error.Closed => return, else => |e| return e, }; try out.putOne(call.io, note); }}A unary handler runs on the stream’s own task. For a streaming method, the
handler, the request reader and the response writer run as tasks of one
group: when one of them fails, or the deadline passes, the others are
canceled. Messages you return or queue may point into call.arena, which
lives until the call ends.
The call
Section titled “The call”anole.grpc.Call gives a handler what it needs about the call:
| Member | What it does |
|---|---|
call.io |
The std.Io for async work (timers, outbound calls, files). |
call.arena |
The call’s arena (the request’s). Not thread-safe: a task you start with io.concurrent needs memory of its own. |
call.metadata(name) |
A request metadata value by lower-case name, or null. |
call.addHeader(name, value) |
Initial metadata. error.HeadersSent once the first response message has gone out. |
call.addTrailer(name, value) |
Trailing metadata, sent with the status. The name is lower-case (letters, digits, -, _, .), the value printable ASCII; a -bin name takes base64 text, as call.metadata returns it. error.InvalidMetadata otherwise. |
call.addBinaryTrailer(name, bytes) |
Binary trailing metadata under a -bin name, base64-encoded for you. |
call.fail(code, message) |
Sets the status; return what it returns. |
call.deadline |
The call’s deadline: the client’s, capped at max_call_duration, or that alone; null without either. |
call.deadlineExceeded() |
True once the deadline has passed. |
call.cancelled() |
The client went away, or the deadline passed. |
call.state(T) |
The Server(T) application state. |
call.data |
RegisterOptions.data of the registration, or null. |
call.requestCompression(), call.protocol(), call.codec() |
How the request came in: .identity or .gzip; an anole.grpc.Protocol (.grpc, .grpc_web, .grpc_web_text, .connect, .connect_stream, .http_json); .proto or .json. |
In a streaming handler, add headers before the first putOne: the writer
task sends the response head with the first message, and once it has,
addHeader returns error.HeadersSent.
Status codes
Section titled “Status codes”Return call.fail(.not_found, "no such user") to end the call with a status.
The codes are anole.grpc.Code: .ok, .cancelled, .unknown,
.invalid_argument, .deadline_exceeded, .not_found, .already_exists,
.permission_denied, .resource_exhausted, .failed_precondition,
.aborted, .out_of_range, .unimplemented, .internal, .unavailable,
.data_loss and .unauthenticated. Code.fromInt converts a number.
Other errors map to a status too:
error.Canceledbecomes DEADLINE_EXCEEDED past the deadline, else CANCELLED;- a request message over the registration’s limit (4 MiB by default) becomes RESOURCE_EXHAUSTED;
- a message that does not decode becomes INTERNAL;
call.res.abort()(Response.abort) resets the stream, and the client reads CANCELLED;- any other error becomes UNKNOWN with the message “Application error
processing RPC” (grpc-java’s wording). The error’s name stays on the
server: it is logged at
warnin scopeanole_grpc, with the path and the request ID when the RequestId middleware runs. Usecall.failfor anything the client should read.
The response’s HTTP status is 200 for gRPC and gRPC-Web, so an access log shows 200 for a failed call; the gRPC status travels in the trailers.
Deadlines and cancellation
Section titled “Deadlines and cancellation”A client sets a deadline with grpc-timeout (Connect: connect-timeout-ms).
When it passes, Anole cancels the handler: any wait on call.io returns
error.Canceled, and the client gets DEADLINE_EXCEEDED. You do not need to
check the deadline yourself while you wait on call.io. A call without a
deadline is not bound by the server’s request timeout, only by
max_call_duration when the registration sets one; its request messages
must still arrive within read_timeout.
When the client resets the stream (RST_STREAM), or the connection is lost,
the handler is canceled the same way: its next wait on call.io returns
error.Canceled, so a call blocked in a sleep, a database query or an
outbound request ends at once. The call ends as CANCELLED, which a client
that reset its stream no longer reads. This holds for HTTP/2 and HTTP/3
(RESET_STREAM, or STOP_SENDING with an error) under Anole’s zio runtime,
which is what anole.Server runs. A connection that ends
cleanly may be only half-closed, so the server first checks with a PING
(every 500 ms) whether the client is gone before canceling handlers whose
request was complete.
call.cancelled() still reports the client going away or the deadline
passing: use it in work that does not wait on call.io, such as a long
computation, and on HTTP/1.1 (gRPC-Web, Connect), where nothing cancels the
handler.
Compression
Section titled “Compression”With -Dcompression (on by default), Anole reads gzip request messages
(grpc-encoding: gzip). A request in any other encoding fails with
UNIMPLEMENTED. Responses are gzipped only for a client whose
grpc-accept-encoding lists gzip, and by default (.response_compression = .mirror, grpc-go’s behaviour) only when the request came gzipped too: most
clients list gzip on every call, and a deflate per message costs more CPU
than a small call itself. .accepted compresses whenever the client accepts
gzip (for large, compressible messages on slow links); .never never does. Connect unary uses HTTP’s Content-Encoding instead, and
compresses JSON responses only.
Try it with curl
Section titled “Try it with curl”Connect unary is plain HTTP, so curl works over HTTP/1.1:
curl -H 'Content-Type: application/json' -d '{"name":"Ada"}' \ http://localhost:8083/greeter.Greeter/SayHello# {"message":"hello, Ada"}Errors come back as JSON with an HTTP status that matches the code, here
400, and a Connect deadline as 504:
curl -H 'Content-Type: application/json' -d '{"name":""}' \ http://localhost:8083/greeter.Greeter/SayHello# {"code":"invalid_argument","message":"name must not be empty"}Trailing metadata appears as trailer-<name> headers in a Connect unary
response.
With grpcurl, pass the .proto
file, since Anole has no server reflection yet. Use -plaintext for h2c and
-insecure for the self-signed TLS listener:
grpcurl -plaintext -import-path examples/grpc/proto -import-path src/grpc/proto -proto greeter.proto \ -d '{"name":"Ada"}' localhost:8083 greeter.Greeter/SayHellogRPC-Web and Connect
Section titled “gRPC-Web and Connect”You need nothing extra on the server: the routes app.grpc adds serve them.
- gRPC-Web, binary and text (base64), works over HTTP/1.1 and HTTP/2. The
status comes in a last body frame flagged
0x80, so browsers can call the service without a proxy such as Envoy. Add CORS middleware if the page is on another origin. - Connect unary (
application/protoorapplication/json) sends one bare message each way. JSON follows the proto3 JSON mapping, through the generatedjsonEncodeandjsonDecode. - Connect streaming (
application/connect+protoor+json) serves all four call modes, with the status in an end-of-stream message.
Connect GET
Section titled “Connect GET”A unary method marked as free of side effects also answers Connect’s GET form, which browsers, proxies and CDNs can cache:
rpc GetGreeting(GreetingRequest) returns (Greeting) { option idempotency_level = NO_SIDE_EFFECTS;}register adds a GET route beside the POST one for such methods (zig-protobuf
keeps the option in the generated _method_options). The query carries the
call: encoding (proto or json, else 415), message (the request,
percent-encoded, or with base64=1 in URL-safe base64, padded or not; absent
is an empty message), compression (identity or gzip, else
UNIMPLEMENTED) and connect=v1 (optional; another version is
INVALID_ARGUMENT). Other parameters are ignored. The answer is a Connect unary
response, compressed by the request’s Accept-Encoding.
curl 'http://localhost:8083/greeter.Greeter/GetGreeting?connect=v1&encoding=json&message=%7B%22name%22%3A%22Ada%22%7D'A GET on any other method is 405 Method Not Allowed.
Anole sends no caching headers of its own. If a handler adds them
(call.addHeader("cache-control", ...)) to a method whose answer depends on
who asks (a cookie, a token, the caller’s address), mark it private, or a
shared cache may hand one user’s answer to another.
JSON transcoding
Section titled “JSON transcoding”A method annotated with google.api.http (google/api/http.proto, as Envoy’s
gRPC-JSON transcoder and grpc-gateway read it) also answers plain REST
requests, JSON in and out, on routes of its own:
import "google/api/annotations.proto";
service Library { rpc GetBook(GetBookRequest) returns (Book) { option (google.api.http) = { get: "/v1/{name=shelves/*/books/*}" additional_bindings { get: "/v1/books/{name}" } }; } rpc CreateBook(CreateBookRequest) returns (Book) { option (google.api.http) = { post: "/v1/{parent=shelves/*}/books" body: "book" }; }}curl 'http://localhost:8083/v1/shelves/s1/books/b1?view=FULL'curl -d '{"title":"Dune"}' 'http://localhost:8083/v1/shelves/s1/books?bookId=dune'register adds these routes beside the gRPC one, for HTTP/1.1 and HTTP/2,
from the annotations zig-protobuf keeps in the generated code: there is no
protoc plugin to run. registerWith(..., .{ .transcode = false }) leaves
them out. The handlers are the same; call.protocol() is .http_json.
The rules are checked at compile time: the template’s syntax, that each path
variable names a single field of a primitive type (a string, number, bool,
enum, or a Timestamp, Duration, FieldMask or wrapper), and that body and
response_body name top-level fields.
Routes
Section titled “Routes”The verbs are get, put, post, delete and patch, plus custom with
any HTTP method the router knows (custom: { kind: "SEARCH" path: "/v1/books" }),
and each of additional_bindings is a route too. Path templates take the
whole syntax:
| Template | Matches | Sets |
|---|---|---|
/v1/books/{name} |
one segment | name |
/v1/{name=shelves/*/books/*} |
the segments, literals included | name to shelves/1/books/2 |
/files/{path=**} |
the rest of the path | path to a/b/c.txt |
/v1/*/x |
any one segment, bound to nothing | |
/v1/{book.name} |
one segment | the nested field book.name |
/v1/{name=books/*}:publish, /v1/books:batchGet |
a custom verb after the last segment |
A one-segment variable is percent-decoded in full; a longer one keeps %2F
as it is, as http.proto asks.
Each template becomes a router route: * a parameter, ** a wildcard, a
verb part of a last literal. What the router cannot say (literals inside a
variable, a verb after a variable) the binding checks itself, so several
bindings can share a route, /v1/books/{name} and /v1/books/{name}:stat
for example; those with a verb are tried first. A path that the route
matches and no binding does is a 404. Transcoding routes follow the router’s
rules:
- A parameter at a position where another route has one takes that route’s
name (the router keeps one name per position; the binding reads its
values from the path, not from
req.params). - A binding whose route is already someone else’s (yours, or another method’s gRPC route) panics at registration, as two bindings that match the same paths do.
**needs at least one segment here (Envoy also matches none).- The router picks a route before the binding is asked: of
/v1/{name}:xand/v1/{name=**},/v1/abcreaches the first’s route and is a 404.
The request
Section titled “The request”The request message is put together the way http.proto describes:
body: "*": the JSON body is the message, with the path variables set over it. Query parameters are ignored.body: "book": the JSON body is that field (any type, a repeated field takes an array); the other fields come from the path and the query.- No
body: everything from the path and the query; a body is ignored.
A query parameter names a field by its proto or JSON name, nested with dots
(author.name=Ada, pageSize=10), and repeats for a repeated field
(tags=a&tags=b). Values read as the proto3 JSON mapping reads strings:
enums by name or number, true/false, 64-bit integers, NaN and
Infinity for floats, base64 for bytes, RFC 3339 for a Timestamp, 1.5s for
a Duration. Parameters for no field are ignored (cache-busters, tracking
tags), and so are those for fields the path or the body set, whichever name
the template and the parameter use ({bookId} in the path and ?book_id= in
the query are the same field, and the path’s value stands). A message or map
field cannot be set from the query.
A request that does not make a message (a value of the wrong type, a body
that is not JSON) fails with INVALID_ARGUMENT before the handler runs. The
body may nest 100 arrays or objects deep; it is limited to the
registration’s max_message_size (RESOURCE_EXHAUSTED past it), and may come
compressed with Content-Encoding.
The response
Section titled “The response”The response message as JSON (application/json), in the proto3 mapping of
the generated jsonEncode: JSON field names, fields at their default left
out, 64-bit integers as strings, oneof members as fields. With
response_body: "books", the body is that field alone: [...] for a
repeated field, its default ([], "", 0) when unset. Responses are
compressed by the request’s Accept-Encoding.
call.addHeader sets response headers, and call.addTrailer sends
grpc-trailer-<name> headers, as grpc-gateway does. The request body is read
under the server’s request timeout, like any route’s; once it is in, a
client’s grpc-timeout header sets the deadline, and without one the call is
not bound by the server’s request timeout, as with gRPC
(max_call_duration caps both).
A binding with a body takes it only as JSON: a request whose body is not
empty and whose Content-Type is not application/json (or a +json type)
is answered 415. Browsers send text/plain and form posts to other sites
without asking first, so this keeps such requests from reaching a handler
that trusts cookies.
A failed call answers the gRPC code’s HTTP status (the same table as Connect: NOT_FOUND 404, INVALID_ARGUMENT 400, UNAUTHENTICATED 401, UNIMPLEMENTED 501, and so on) with a google.rpc.Status body:
{"code":5,"message":"no such book","details":[]}Streaming
Section titled “Streaming”A server-streaming method answers one JSON array, as Envoy’s transcoder
does, sent element by element as the handler queues messages: [ with the
first, , before each next one, ] when the handler returns. No message is
[].
A failure before the first message is an ordinary error response. After it
the status line has gone out as 200, so the array is cut short instead
(Response.abort): on HTTP/2 and HTTP/3 the stream is reset, on HTTP/1.1 the
connection closes before the body’s last chunk. A client never reads a complete array from a failed
call; the server logs the status at warn. On HTTP/2, trailing metadata of a
successful stream goes out as HTTP trailers (HTTP/1.1 has none to carry it).
Client- and bidirectional-streaming methods are not transcoded: their routes answer 501 with UNIMPLEMENTED. Call them over gRPC, gRPC-Web or Connect.
Not supported: rules from a service config (selector; annotations only),
and map fields from query parameters.
The health service
Section titled “The health service”anole.grpc.Health serves grpc.health.v1.Health (Check, List and Watch).
app.grpcHealth() registers one that the server owns, and marks every
service NOT_SERVING when the server starts to drain:
try app.grpcHealth().set("greeter.Greeter", .SERVING);On a router of your own:
var health: anole.grpc.Health = .init(init.gpa, app.io());defer health.deinit();health.registerOn(void, app.router);try health.set("greeter.Greeter", .SERVING);The empty service name is the server as a whole, and starts SERVING. Change a
status with set(name, .NOT_SERVING), or mark everything NOT_SERVING with
shutdown(). Watch streams each change. Check on an unknown name fails with
NOT_FOUND. Each Health is its own registry: two servers in one process can
each register their own, or both register the same one. It must outlive the
router.
curl -H 'Content-Type: application/json' -d '{"service":"greeter.Greeter"}' \ http://localhost:8083/grpc.health.v1.Health/Check# {"status":"SERVING"}This service is separate from the HTTP /healthz and /readyz in
anole.observe.
The client
Section titled “The client”anole.grpc.Client(Service) makes typed calls over a Channel, which
holds an anole.h2.ClientConnection (.conn) or an
anole.h3.ClientConnection (.h3, see HTTP/3). Pass it the same generated service type the
server uses; only its names and message types matter.
const Greeter = pb.Greeter(anole.grpc.Call, anyerror);
const conn = try anole.h2.ClientConnection.connect(gpa, io, .{ .host = "127.0.0.1", .port = 8083 });defer conn.close();var channel: anole.grpc.Channel = .{ .conn = conn, .authority = "localhost" };const client: anole.grpc.Client(Greeter) = .{ .channel = &channel };
const r = try client.unary("SayHello", arena, .{ .name = "Ada" }, .{ .timeout = .fromSeconds(1), .metadata = &.{.{ .name = "x-salutation", .value = "hi" }},});if (r.status.code == .ok) std.log.info("{s}", .{r.message.?.message});io is the runtime’s std.Io: req.io or call.io in a handler. Results
and received messages live in arena. r.headers and r.trailers hold the
response metadata.
CallOptions takes a timeout (sent as grpc-timeout, and the client
resets the stream past it too), extra metadata with lower-case names,
compression = .gzip, and max_received_bytes. Received messages live in
arena until you free it, so a call takes at most the channel’s
max_received_bytes (64 MiB) of responses in all, each counted at its size
plus 64; past it the call is canceled with RESOURCE_EXHAUSTED. For a long
server stream, raise it per call or on the Channel, and use an arena you
reset between calls. A call without a timeout has no time limit on the
client side, whatever the connection’s default request timeout.
For TLS, connect with .tls = .{} (the system trust store), or name a CA
(.tls = .{ .ca = .{ .pem = ca_pem } }, see
TLS options), and set .scheme = "https" on the
channel. The connection negotiates h2 by ALPN.
Every call mode goes through stream:
var s = try client.stream("Chat", arena, .{});defer s.deinit();try s.send(.{ .text = "a" });try s.closeSend();while (try s.receive()) |reply| { // ...}const status = s.status.?; // set once receive returns nulls.cancel() resets the call. anole.grpc.openCall opens a call by path, for
code without a generated service type. A non-200 HTTP answer (from a proxy,
say) maps to gRPC’s codes, and a call that ends without a status after its
deadline reports DEADLINE_EXCEEDED.
HTTP/3
Section titled “HTTP/3”Native gRPC over HTTP/3 follows gRFC G2: the same headers, application/grpc
content type, length-prefixed messages in DATA frames and status in
trailers as over HTTP/2.
Serving. There is nothing to switch on. A service you registered answers
native gRPC on every listener that serves HTTP/2 or HTTP/3, the C ABI’s
services too. The Alt-Svc header the TLS listener already puts on its
responses advertises HTTP/3 to clients that start on HTTP/2.
The client. Give a channel an HTTP/3 connection, or let it own one:
const conn = try anole.h3.ClientConnection.connect(gpa, io, .{ .host = "api.example", .port = 443 });defer conn.close();var channel: anole.grpc.Channel = .{ .h3 = conn, .authority = "api.example", .scheme = "https" };
// Or a channel that dials and redials for itself:const owned = try anole.grpc.Channel.connect(gpa, io, .{ .host = "api.example", .port = 443, .tls = .{} }, .{ .http = .auto });defer owned.close();.http = .h2 (the default) is HTTP/2 only. .h3 is HTTP/3 by prior
knowledge: the server must serve it on http3.port (default: the same
number over UDP), and a server without it fails the call with UNAVAILABLE.
.auto starts on HTTP/2; once a response’s Alt-Svc advertises h3 for the
host, the next call dials HTTP/3 and later calls use it. If QUIC does not
come up within http3.connect_timeout (3 s), calls stay on HTTP/2 and
HTTP/3 is not tried again for http3.retry_after (5 minutes). A call
HTTP/3 provably did not carry (never processed, or the connection lost before
a response) goes once more over HTTP/2. UnaryResult.transport says which
version carried a call. Both modes need tls.
Limits. A connection takes 100 concurrent calls (HTTP/2: 250). Each call
has a 1 MiB send buffer and receive window (http3.stream_window), and a
connection holds 4 MiB unacknowledged. Message limits (max_message_size,
max_received_bytes) are the same as on HTTP/2.
Deadlines and cancellation. Past a deadline the server cancels the
handler and sends DEADLINE_EXCEEDED in the trailers, then STOP_SENDING if the
request side is still open. A client that cancels, or passes its own
deadline, resets the stream with H3_REQUEST_CANCELLED, and the handler’s
next wait returns error.Canceled, as on HTTP/2. A server that resets a
call maps to a status in the client by its code, on both transports:
refused (REFUSED_STREAM, H3_REQUEST_REJECTED) is UNAVAILABLE, cancelled
is CANCELLED, excessive load is RESOURCE_EXHAUSTED, INADEQUATE_SECURITY is
PERMISSION_DENIED, and anything else INTERNAL. Only UNAVAILABLE is retried by
Channel.retry.
Keep-alive and 0-RTT. While a call is open, QUIC PINGs keep the connection alive, so a quiet stream outlives the 30 s idle timeout; an idle connection still closes at it. Calls are never sent in 0-RTT.
Which clients speak it: grpc-dotnet (HttpVersion = 3.0), connect-go on
quic-go with WithGRPC, buf curl --http3. grpc-go, grpc-java and grpc-node
do not.
gRPC between processes on one host
Section titled “gRPC between processes on one host”A gRPC service can listen on a Unix domain socket, with no TLS and no network port, for calls between a web app and a worker on the same machine:
var app = try anole.Server(void).init(gpa, .{ .listeners = &.{.{ .unix = "/run/app/worker.sock" }},}, {});app.grpc(pb.Worker, .{ .Run = run });The listener serves cleartext HTTP/2 (prior knowledge), so gRPC works on it
without further options. The socket file is created 0660 (owner and group
may connect; others may not): change it with unix_mode. A socket file left
by a server that is gone is replaced; one something still listens on is an
error. The client names the socket as gRPC does:
const channel = try anole.grpc.Channel.connect(gpa, io, .{ .host = "unix:/run/app/worker.sock", .port = 0 }, .{});defer channel.close();unix:/path and unix:///path work everywhere; Linux also takes
unix-abstract:name. The connection is cleartext HTTP/2 and the authority is
localhost, as in grpc-go; tls on a Unix target is refused
(error.TlsOnUnixSocket). Services written in Node or Python through the C
ABI get the same listener ("unix" in listeners); the ABI has no outbound
gRPC client yet. Windows needs AF_UNIX support and comes after 0.4.
Interop
Section titled “Interop”grpc-go v1.84.0’s interop client passes all 14 cases it runs against Anole’s
server (tests/grpc/interop/run.sh), and Anole’s client passes 11 cases
against grpc-go’s server (tests/grpc/interop/run-client.sh), each over h2c
and, with --tls, over TLS with a certificate made for the run. Over HTTP/3,
grpc-dotnet v2.84.0’s interop client passes 18 cases against Anole’s server
and against the C ABI’s, and Anole’s client passes 11 against grpc-dotnet’s
Kestrel server (tests/grpc/h3/run.sh, in Docker with libmsquic). grpc’s
HTTP/2 server checks (tools/http2_interop, tests/grpc/interop/run-http2.sh)
pass their framing cases over h2c and TLS and the TLS profile, except five
results the tool itself causes (listed with reasons in
tests/grpc/interop/http2-known-failures.txt). All of it runs in CI.
From other languages
Section titled “From other languages”The C ABI (libanole, what anole-node and anole-py are built on) serves
gRPC services whose methods are written in the binding’s language: Anole
keeps the protocols, framing, compression, deadlines, limits, Connect,
gRPC-Web, JSON transcoding and the health service, and hands each call to
the language as protobuf bytes. See the C ABI’s gRPC section.
The same interop client passes its 14 cases against a C implementation of
the test service on that ABI (tests/capi/grpc_interop.c).
Not there yet
Section titled “Not there yet”- gRPC over HTTP/3 in the C ABI’s client: the ABI has no outbound gRPC client of any kind yet.
- Server reflection.
- Canceling the handler of a gRPC-Web or Connect call over HTTP/1.1 when the
client goes away (check
call.cancelled()).
Client retries and redial
Section titled “Client retries and redial”Channel.retry, Channel.retry_codes (default UNAVAILABLE) and Channel.breaker apply to unary calls: each retry sends grpc-previous-rpc-attempts and the remaining grpc-timeout, and a grpc-retry-pushback-ms trailer is obeyed. Channel.connect(gpa, io, connect_options, .{}) makes a channel that owns its connection and redials it after a GOAWAY or a failure. See Retries and circuit breakers.
OpenAPI from your protos
Section titled “OpenAPI from your protos”app.openapi(.{}) describes a gRPC service too: each annotated method as its
REST route, each other unary method in Connect’s form, with the .proto
comments as descriptions and field_behavior as required and readOnly.
See OpenAPI.