tonic and gRPC

32 min read
On this page

gRPC describes RPC methods and message types using service definitions, while tonic provides the asynchronous implementation for Rust, commonly combined with Tokio, Tower, and Prost. Generated code reduces the need for hand-written serialization and interface boilerplate, but the two ends of the network will not automatically recompile just because one side modifies the .proto file.

There are three layers: compile-time type checking of the protocol, online encoding compatibility, and business semantics. HTTP APIs can also use descriptions and code generation tools like OpenAPI; the reason for choosing gRPC should come from the calling model, streaming capabilities, and ecosystem requirements, rather than dismissing other types of interfaces as lacking contracts.

greeter.protoMessage field numbers and RPC request/response types1protoc + tonic-prost-buildGenerate Prost messages, service traits, and client/server wrappers2Implement Greeter traitValidate business input, return Response or Status3tonic Transport Layer4From Protocol Files to Callable Services: Code Generation and Runtime Each Do PartHTTP/2, message encoding, metadata, and flow controlCode generation checks local types; cross-version compatibility and business constraints still require separate design.

Understand the Four Types of RPC First

FormNumber of Request MessagesNumber of Response MessagesTypical Use Case
Unary11A single query or command
Server streaming1MultipleSending query results incrementally
Client streamingMultiple1Uploading in batches and then aggregating
Bidirectional streamingMultipleMultipleIndependent progress in both directions

Bidirectional streaming does not require sending one response for every request received, nor does it guarantee a one-to-one correspondence between the two directions. Messages within each direction are ordered, but the application still needs to define message IDs, completion conditions, and recovery methods after failures.

Minimal Complete Project: Specify Code Generation Tool Versions

The following uses the tonic 0.14 series. The Prost integration for this series is located in tonic-prost and tonic-prost-build; do not directly mix in the old tutorial's tonic_build::compile_protos. You need protoc installed locally, and save Cargo.lock to record the actual resolved versions. tonic, tonic-prost-build

Cargo.toml:

[package]
name = "grpc-demo"
version = "0.1.0"
edition = "2024"

[dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tonic = "0.14"
tonic-prost = "0.14"
prost = "0.14"

[build-dependencies]
tonic-prost-build = "0.14"

proto/greeter.proto:

syntax = "proto3";
package greeter;
service Greeter {
  rpc SayHello(HelloRequest) returns (HelloReply);
}
message HelloRequest { string name = 1; }
message HelloReply { string message = 1; }

build.rs:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    println!("cargo:rerun-if-changed=proto/greeter.proto");
    tonic_prost_build::configure()
        .compile_protos(&["proto/greeter.proto"], &["proto"])?;
    Ok(())
}

src/lib.rs:

pub mod greeter {
    tonic::include_proto!("greeter");
}

src/bin/server.rs:

use grpc_demo::greeter::{HelloReply, HelloRequest};
use grpc_demo::greeter::greeter_server::{Greeter, GreeterServer};
use tonic::{Request, Response, Status};

#[derive(Default)]
struct HelloService;

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

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    tonic::transport::Server::builder()
        .add_service(GreeterServer::new(HelloService))
        .serve("127.0.0.1:50051".parse()?)
        .await?;
    Ok(())
}

src/bin/client.rs:

use grpc_demo::greeter::{HelloRequest, greeter_client::GreeterClient};
use std::time::Duration;
use tonic::Request;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut client = GreeterClient::connect("http://127.0.0.1:50051").await?;
    let mut request = Request::new(HelloRequest { name: "Rust".into() });
    request.set_timeout(Duration::from_secs(2));
    let reply = client.say_hello(request).await?.into_inner();
    println!("{}", reply.message);
    Ok(())
}

Run cargo run --bin server and cargo run --bin client in two separate terminals; the client should print Hello, Rust!. Local plaintext listening here is for observing the request chain; real deployments should configure TLS, authentication, and resource limits according to the environment.

#[tonic::async_trait] is used to match the trait form generated by this series. Rust has supported native async fn in traits since 1.75; keeping the macro cannot be interpreted as the language "not yet supporting" it; native async traits, dynamic dispatch, and conventions used by generation libraries need to be viewed separately.

Why Modifying .proto Doesn't Necessarily Break Old Clients

Protobuf binary encoding relies on field numbers. When adding compatible fields, old clients may still successfully read fields they recognize; whether local Rust code needs modification depends on whether it is regenerated and how types are constructed. Online compatibility cannot be proven solely by a single repository compiling successfully.

After deleting a field, you should retain its number, and usually its name to prevent future misuse; do not reuse old numbers to express different meanings. Changes to field types must be judged item by item according to wire compatibility rules. Even if the encoding can still be decoded, changing the unit from seconds to milliseconds might directly break business semantics. Proto3 Update Rules

When distinguishing between "not provided" and "provided with a default value," you should explicitly use field presence design, such as applicable optional fields. An empty string does not automatically become a validation error just because the type is string, which is why the handler above explicitly checks name.

Verify Field Presence and Old Relay Nodes with Two Message Types

Below is an independent src/bin/compat.rs, using the project's existing Prost dependency, directly declaring messages to observe both old and new structures in the same program. Field number 1 retains name, and version 2 adds an optional locale field with number 2.

use prost::Message;
#[derive(Clone, PartialEq, Message)]
struct V1 {
    #[prost(string, tag = "1")]
    name: String,
}
#[derive(Clone, PartialEq, Message)]
struct V2 {
    #[prost(string, tag = "1")]
    name: String,
    #[prost(string, optional, tag = "2")]
    locale: Option<String>,
}
fn main() {
    let absent = V2 { name: String::new(), locale: None };
    let empty = V2 { name: String::new(), locale: Some(String::new()) };
    assert!(absent.encode_to_vec().is_empty());
    assert_eq!(empty.encode_to_vec(), [0x12, 0]);

    let new = V2 { name: "Rust".into(), locale: Some("zh-CN".into()) };
    let bytes = new.encode_to_vec();
    let old = V1::decode(bytes.as_slice()).unwrap();
    assert_eq!(old.name, "Rust");
    let relayed = old.encode_to_vec();
    let decoded = V2::decode(relayed.as_slice()).unwrap();
    assert_eq!(decoded.name, "Rust");
    assert_eq!(decoded.locale, None);
}

optional maps missing and explicitly empty values to None and Some(""). In this example, 0x12 is the key for field number 2, wire type 2, and the following 0 indicates a zero-byte length; thus, even if the business strings are both empty, their online representations are different. Prost Field Representation

The old V1 can read the recognized name from the new data, but this Prost message structure will not retain the unknown locale when re-encoding. Thus, "old clients can read messages with new fields" and "new fields can still reach the destination after being decoded and re-encoded by old nodes" are two different compatibility requirements. If the old node merely passes through the raw message bytes, no lossy reconstruction occurs here; specific chains must be verified according to the actual conversion process.

Check Before Release by Read/Write Direction

ScenarioResult to Check
New Write → Old ReadCan unknown fields be skipped, and does the old business still work correctly?
Old Write → New ReadDo missing values have reasonable meanings? Don't assume all are valid by default.
New Write → Old Relay → New ReadDoes re-encoding or JSON conversion lose new information?
New Enum Value → Old LogicIs the original integer preserved? Is the default meaning incorrectly applied?
Data Compatible but Unit ChangedHave business calculations, ranges, and displays changed?

Gradual release usually lets the reader accept the new format first, then enables new writers; rollback also requires checking if already written new data can be reasonably handled by the old version. Retaining field numbers prevents encoding misreading, but does not automatically solve changes in units, permissions, or state machine semantics.

Can an old reader preserve a new field?

Compare two subtly different messages: None omits field 2, while Some("") sends its key and zero length. Raw forwarding preserves the distinction; the article’s old Prost V1 knows only name and loses locale when rebuilding. Compatibility must examine the whole read/write path, not just whether an old endpoint decodes.

Preparing the visual
Can an old reader preserve a new field?

Some("") adds field 2 as 12 00; old Prost decode/re-encode drops undeclared fields, while raw forwarding preserves them.

Status, Deadlines, and Retries

Status represents the RPC result; you cannot just look at the HTTP response status. Common classifications are as follows, see gRPC Status Codes for complete code definitions:

CodeJudgment in Application
InvalidArgumentThe request itself does not meet requirements; retrying as-is is usually futile
Unauthenticated / PermissionDeniedMissing valid identity / Permission not allowed
NotFound / AlreadyExistsRelated to business resource state
ResourceExhaustedQuota or capacity limits; need throttling or budget adjustment
UnavailableMight be temporarily unavailable, but still need to judge if the operation can be safely retried
DeadlineExceeded / CancelledThe caller stopped waiting; does not prove the remote end didn't produce effects

Clients should have a deadline; the total budget must cover queuing, connection, processing, and possible retries. Request::set_timeout writes to the grpc-timeout metadata, which is not the same operation as setting a local transport timeout only; the setting above occurs after connect, so it does not set a two-second limit for the connection establishment itself.

After service cancellation, the application must also stop background work it created. When sending RPCs downstream, propagate the remaining budget instead of giving each layer a full timeout; different languages and libraries have different levels of support for propagation, which must be explicitly verified. gRPC Deadlines

Streaming Lifecycles Are Longer

When the server returns Response<Stream>, it only indicates that a response stream has been established, not that all business messages are complete. Subsequently, stream elements are usually Result<Message, Status>; the consumer must distinguish between normal termination and errors, and cannot silently swallow errors with while let Some(Ok(...)).

Sending to a bounded mpsc channel via send().await can feed back slow consumers to the producer. A send failure indicates the receiver has disappeared; you should end the corresponding work instead of unwrap; unbounded queues might turn network backpressure into memory growth. Background tasks also need to hold and respond to appropriate cancellation signals.

Message size limits are not equal to the total size limit of the entire stream. Long streams also require duration, total bytes, concurrency, or business window controls; if the client only pulls part of the data and discards it, the server should also release associated resources.

Message 1: Ok(reply)Can deliver partial results firstMessage 2: Err(Status)For example, UnavailableCaller Records FailureReceived data is not undoneNormal EOF and Termination Errors Must Be Handled SeparatelyAfter Receiving Partial Messages, the Final Status Still Determines How This RPC EndsIf the business requires a complete set, submit only after normal termination; for incremental delivery, define recovery points and deduplication.

Verify Producer and Consumer Termination Protocols In-Process

Add "sync" to the previous tokio dependency features, then save the following src/bin/stream_state.rs. It uses a bounded channel common to the production side of streaming services, directly observing Result in the queue; this step does not require listening on a port or calling a remote service.

use tokio::sync::mpsc;
use tonic::{Code, Status};
use grpc_demo::greeter::HelloReply;

#[tokio::main]
async fn main() {
    let (sender, mut receiver) = mpsc::channel::<Result<HelloReply, Status>>(1);
    let producer = tokio::spawn(async move {
        if sender.send(Ok(HelloReply { message: "part-1".into() })).await.is_err() {
            return;
        }
        let _ = sender.send(Err(Status::unavailable("source interrupted"))).await;
        // No more business messages produced after termination error; release sender on exit.
    });
    let mut received = Vec::new();
    let mut failure = None;
    while let Some(item) = receiver.recv().await {
        match item {
            Ok(reply) => received.push(reply.message),
            Err(status) => {
                failure = Some(status.code());
                break;
            }
        }
    }
    drop(receiver);
    producer.await.unwrap();
    assert_eq!(received, ["part-1"]);
    assert_eq!(failure, Some(Code::Unavailable));

    let (sender, receiver) = mpsc::channel::<Result<HelloReply, Status>>(1);
    drop(receiver);
    assert!(sender.send(Ok(HelloReply { message: "unused".into() })).await.is_err());
}

A bounded capacity of 1 only constrains the number of queued message items, not the string byte count in one message, nor the objects the producer has already constructed before send. The producer stops sending when it encounters the receiver leaving; if it still holds database cursors or has started other tasks, those resources should be released or cancelled simultaneously.

Actual tonic clients can handle messages, normal termination, and errors separately according to the Result<Option<Message>, Status> of stream.message().await; you cannot treat Err as None. If the business requires a complete snapshot in one go, part-1 in the example above can only be considered temporary data; it should not be submitted as a complete result upon final failure; if the business allows incremental consumption, message sequence numbers, recovery cursors, and idempotent processing are needed to handle reconnections.

Local Verification and Transport Verification Cover Different Issues

You can directly call the generated Greeter trait methods to verify empty name and normal responses, and then use Prost tests for field encoding; these checks quickly locate business and protocol model issues. The transport layer still needs separate checks for metadata, TLS, message limits, deadlines, and stream termination status, because directly calling the handler does not go through tonic's HTTP/2 encoding/decoding and middleware.

Similarly, the metadata generated by the client's set_timeout needs to be correctly understood by the server and relay chains. It does not add budget for DNS/connection waiting before creating the Channel, nor does it mean background tasks will automatically stop. Establish a total deadline around a user operation, and then separately verify connection establishment, RPC waiting, and application resource cleanup.

What Interceptors Are Suitable For

The Interceptor interface handles Request<()> and synchronously returns a request or Status, suitable for reading metadata, injecting lightweight context, and immediate rejection. It cannot directly await remote authentication, nor does it provide arbitrary response body transformation capabilities. Interceptor

When asynchronous external queries, complete observation of requests and responses, or finer-grained flow control are needed, use Tower services suitable for insertion layers, or handle them explicitly in the business service. Regardless of where they are placed, you should clarify how errors become Status and how resources are cleaned up when streams end, see Tower and Middleware.