8 min read #rust #async-ecosystem
On this page

tonic (gRPC)

gRPC uses protobuf to define strongly-typed service interfaces—changing a .proto file requires all callers to update, otherwise compilation fails (a compiler-verified contract, not a convention). tonic is Rust’s gRPC implementation; codegen generates Rust traits, streaming types (unary/client/server/bidi), and an interceptor layer from .proto files, each corresponding to different usage scenarios and cross-cutting logic.

Differences Between gRPC and REST

REST expresses operations via HTTP methods and URLs, with payloads typically in JSON. gRPC uses protobuf to define strongly-typed service interfaces, with payloads encoded as binary protobuf messages. The difference is not just format—it’s the programming model. REST interface definitions are "conventions" (API docs + boilerplate). gRPC interface definitions are compiler-verified contracts—changing a .proto file requires all callers to update, otherwise compilation fails.

tonic is the most mature gRPC implementation in the Rust ecosystem, built on tokio + tower + prost (the protobuf compiler).

From .proto to Rust Types

syntax = "proto3";
package greeter;

service Greeter {
    rpc SayHello (HelloRequest) returns (HelloReply);           // unary: 1 request → 1 response
    rpc StreamHello (HelloRequest) returns (stream HelloReply); // server streaming
    rpc Chat (stream ChatMessage) returns (stream ChatMessage); // bidi streaming
}

message HelloRequest { string name = 1; }
message HelloReply { string message = 1; }

In build.rs, the tonic_build::compile_protos() call invokes protoc + prost to generate:

  • GreeterServer<T: Greeter> — server wrapper (wraps the Greeter trait as a tower Service)
  • Greeter trait — interface for business logic (methods you implement)
  • GreeterClient — client stub (async methods for callers to use)

The generated code implements trait methods using #[tonic::async_trait]—this allows traits to contain async fn (native Rust traits do not yet support async fn).

Server: Implementing the Trait

struct MyGreeter;
#[tonic::async_trait]
impl Greeter for MyGreeter {
    async fn say_hello(&self, req: Request<HelloRequest>) -> Result<Response<HelloReply>, Status> {
        let name = req.into_inner().name;
        if name.is_empty() {
            return Err(Status::invalid_argument("name is required"));
        }
        Ok(Response::new(HelloReply {
            message: format!("Hello {}!", name),
        }))
    }
}

tonic::transport::Server::builder()
    .add_service(GreeterServer::new(MyGreeter))
    .serve("[::1]:50051".parse().unwrap()).await?;

Status is gRPC’s standard error type—not an HTTP status code. gRPC defines 17 status codes: Ok, Cancelled, InvalidArgument, NotFound, AlreadyExists, PermissionDenied, Unauthenticated, ResourceExhausted, Unimplemented, Internal, Unavailable, DeadlineExceeded. Each code has a clear semantic meaning—unlike HTTP, where 400 might mean "malformed format" or "missing parameter."

Streaming: More Than "One Request, One Response"

// Server streaming: server sends data incrementally
async fn stream_hello(&self, req: Request<HelloRequest>) -> Result<Response<Self::StreamHelloStream>, Status> {
    let mut stream = tokio_stream::iter(vec!["Hello", "Bonjour", "Hola"].into_iter()
        .map(|s| Ok(HelloReply { message: s.into() })));
    Ok(Response::new(Box::pin(stream)))
}

// Bidirectional streaming: both sides send simultaneously
async fn chat(&self, req: Request<Streaming<ChatMessage>>) -> Result<Response<Self::ChatStream>, Status> {
    let mut in_stream = req.into_inner();
    let (tx, rx) = tokio::sync::mpsc::channel(4);
    tokio::spawn(async move {
        while let Some(Ok(msg)) = in_stream.next().await {
            tx.send(Ok(ChatReply { response: format!("echo: {}", msg.text) })).await.unwrap();
        }
    });
    Ok(Response::new(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx))))
}

Key point about streaming: server streaming = server returns stream T, and the client receives items one by one. Bidirectional = both sides send streams independently. This differs from WebSocket’s "bidirectional channel"—in gRPC streaming, each message is still an independent, strongly-typed protobuf message.

Interceptors: Request-Level Middleware

fn auth_interceptor(mut req: Request<()>) -> Result<Request<()>, Status> {
    let token = req.metadata().get("authorization")
        .ok_or_else(|| Status::unauthenticated("missing token"))?;
    validate_token(token)?;
    Ok(req)
}

Server::builder()
    .add_service(GreeterServer::with_interceptor(MyGreeter, auth_interceptor))

Interceptors execute before handlers—enabling authentication, rate limiting, metadata injection, etc. The difference from tower Layer: interceptors are a tonic-specific concept, while Layer is tower’s generic abstraction. Under the hood, tonic interceptors are essentially thin wrappers around tower Service.

References

  • tonic: github.com/hyperium/tonic
  • gRPC: grpc.io (spec, status codes)
  • protobuf: protobuf.dev (encoding, proto3 language guide)

Keywords: tonic, gRPC, protobuf, streaming, interceptor, Status, unary, bidirectional