Guide contents

Lesson 19 of 30

gRPC Basics: Protobuf and Unary RPC with Tonic

gRPC is a remote procedure call framework. A caller invokes a named method on a remote service, and the framework sends the request and response over HTTP/2. This lesson defines a Protocol Buffers schema, evolves it safely, generates Rust code with Tonic, and runs a unary server and client.

Term Meaning
HTTP/2 A protocol that carries many independent streams over one TCP connection
Protocol Buffers (protobuf) A binary message format and a schema language
Field number The integer that identifies a field on the wire
Unary RPC One request message, one response message
Tonic A Rust gRPC implementation built on Hyper and Tokio
Prost The Rust library that generates message structs from protobuf

Project setup

Tonic generates code from the schema at build time and needs the protoc compiler (apt install protobuf-compiler, or brew install protobuf). Optional fields in proto3 need protoc 3.15 or newer.

Cargo.toml

TOML
[package]
name = "catalog"
version = "0.1.0"
edition = "2021"

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

[build-dependencies]
tonic-build = "0.13"

build.rs

Rust
fn main() -> Result<(), Box<dyn std::error::Error>> {
    tonic_build::compile_protos("proto/catalog.proto")?;
    Ok(())
}

Cargo runs build.rs before compiling the crate. The generated code lands in OUT_DIR.

src/lib.rs

Rust
pub mod pb {
    tonic::include_proto!("storefront.catalog.v1");
}

include_proto! takes the protobuf package name. Server and client binaries share the generated types through this library crate.

Example 1: What gRPC sends over HTTP/2

A gRPC call is an HTTP/2 request. The method is always POST, and the path is /<package>.<Service>/<Method>. The body is a sequence of length-prefixed protobuf messages. The final status arrives in trailers, which are headers sent after the body.

Text
POST /storefront.catalog.v1.Catalog/GetProduct HTTP/2
content-type: application/grpc
te: trailers

<5-byte prefix: compression flag + message length><protobuf bytes>
  • One connection carries many concurrent calls, each on its own stream.
  • Streams stay open in both directions, which gRPC streaming uses.
  • A call can return HTTP status 200 and still fail, because the failure is in the grpc-status trailer. Browsers cannot read trailers from fetch, so browser clients need gRPC-Web.

Example 2: A protobuf schema

Catalog owns products, as decided in service architecture. Version 1 of the schema has one method.

proto/catalog.proto (version 1)

PROTOBUF
syntax = "proto3";

package storefront.catalog.v1;

service Catalog {
  rpc GetProduct(GetProductRequest) returns (Product);
}

message GetProductRequest {
  int64 id = 1;
}

message Product {
  int64 id = 1;
  string name = 2;
  int64 price_cents = 3;
  ProductStatus status = 4;
  repeated string tags = 5;
  string legacy_sku = 6;
}

enum ProductStatus {
  PRODUCT_STATUS_UNSPECIFIED = 0;
  PRODUCT_STATUS_ACTIVE = 1;
  PRODUCT_STATUS_DISCONTINUED = 2;
}
  • package namespaces the types and becomes part of the gRPC path.
  • Each field has a type, a name, and a field number. The number, not the name, identifies the field in the encoded bytes.
  • repeated marks a list.
  • The first enum value must be 0. A field that was never set decodes as the zero value, so 0 means "unspecified" and not a real state.
  • Proto3 scalars have no "absent" state: an unset int64 reads as 0. The optional keyword adds presence tracking and generates Option<T> in Rust.
  • Field numbers 1 to 15 use one byte of tag, so frequent fields get low numbers.

Example 3: Schema evolution

Services deploy independently, so old clients call new servers and the reverse. A decoder ignores field numbers it does not know, which makes additive changes safe.

Change Safe Reason
Add a field with a new number Yes Old readers skip it. New readers see the zero value when it is missing
Remove a field and reserve its number and name Yes Reservation blocks reuse
Rename a field Wire only Names are not on the wire, but generated code and JSON change
Change a field number No Old data decodes into the wrong field
Reuse a removed number No Old data decodes with the new meaning
Change to an incompatible type No Wire encodings differ
Add an enum value Usually Old readers must handle unknown values
Remove an RPC No Old clients still call it

Version 2 removes legacy_sku, reserves its number and name, adds description and an optional sale price, and adds a CreateProduct method. Old clients built from version 1 still work: they never send fields 7 and 8 and ignore them in responses.

proto/catalog.proto (version 2)

PROTOBUF
syntax = "proto3";

package storefront.catalog.v1;

service Catalog {
  rpc GetProduct(GetProductRequest) returns (Product);
  rpc CreateProduct(CreateProductRequest) returns (Product);
}

message GetProductRequest {
  int64 id = 1;
}

message CreateProductRequest {
  Product product = 1;
}

message Product {
  reserved 6;
  reserved "legacy_sku";

  int64 id = 1;
  string name = 2;
  int64 price_cents = 3;
  ProductStatus status = 4;
  repeated string tags = 5;
  string description = 7;
  optional int64 sale_price_cents = 8;
}

enum ProductStatus {
  PRODUCT_STATUS_UNSPECIFIED = 0;
  PRODUCT_STATUS_ACTIVE = 1;
  PRODUCT_STATUS_DISCONTINUED = 2;
}

The compiler rejects a later declaration of field 6 or the name legacy_sku. A breaking redesign goes into a new package version (v2) served beside v1 until callers migrate.

Example 4: What Tonic generates

Generated item From Purpose
Product, GetProductRequest, CreateProductRequest message Structs with public fields, deriving Debug, Clone, Default
ProductStatus enum A Rust enum with variants Unspecified, Active, Discontinued
catalog_server::Catalog service A trait to implement, one async method per RPC
catalog_server::CatalogServer service A wrapper that turns an implementation into a Tower service
catalog_client::CatalogClient service A client with one async method per RPC
  • Enum fields are stored as i32 in the struct. Cast with ProductStatus::Active as i32 to set one. The generated getter product.status() returns the enum.
  • A field of message type, such as CreateProductRequest.product, is Option<Product>. The optional int64 field is Option<i64>.
  • Prost keeps unknown enum numbers as plain i32, so a value from a newer server stays representable.

Example 5: A unary server

A unary method receives tonic::Request<T> and returns Result<tonic::Response<U>, tonic::Status>. Request wraps the message with metadata. Status is the gRPC error type, holding a code and a message.

src/main.rs

Rust
use std::{
    collections::HashMap,
    sync::{
        atomic::{AtomicI64, Ordering},
        Mutex,
    },
};

use catalog::pb::{
    catalog_server::{Catalog, CatalogServer},
    CreateProductRequest, GetProductRequest, Product,
};
use tonic::{transport::Server, Request, Response, Status};

struct CatalogService {
    products: Mutex<HashMap<i64, Product>>,
    next_id: AtomicI64,
}

impl CatalogService {
    fn new() -> Self {
        Self {
            products: Mutex::new(HashMap::new()),
            next_id: AtomicI64::new(1),
        }
    }
}

#[tonic::async_trait]
impl Catalog for CatalogService {
    async fn get_product(
        &self,
        request: Request<GetProductRequest>,
    ) -> Result<Response<Product>, Status> {
        let id = request.into_inner().id;
        let products = self.products.lock().unwrap();
        match products.get(&id) {
            Some(product) => Ok(Response::new(product.clone())),
            None => Err(Status::not_found(format!("product {id} not found"))),
        }
    }

    async fn create_product(
        &self,
        request: Request<CreateProductRequest>,
    ) -> Result<Response<Product>, Status> {
        let mut product = request
            .into_inner()
            .product
            .ok_or_else(|| Status::invalid_argument("product is required"))?;

        if product.name.is_empty() {
            return Err(Status::invalid_argument("name must not be empty"));
        }
        if product.price_cents < 0 {
            return Err(Status::invalid_argument("price must not be negative"));
        }
        if let Some(sale) = product.sale_price_cents {
            if sale >= product.price_cents {
                return Err(Status::invalid_argument("sale price must be below price"));
            }
        }

        product.id = self.next_id.fetch_add(1, Ordering::SeqCst);
        self.products
            .lock()
            .unwrap()
            .insert(product.id, product.clone());
        Ok(Response::new(product))
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr = "127.0.0.1:50051".parse()?;
    println!("catalog listening on {addr}");
    Server::builder()
        .add_service(CatalogServer::new(CatalogService::new()))
        .serve(addr)
        .await?;
    Ok(())
}
  • #[tonic::async_trait] lets the generated trait contain async fn methods.
  • request.into_inner() consumes the wrapper and returns the message. request.metadata() reads headers.
  • Trait methods take &self, so shared mutable state needs interior mutability: here a Mutex and an atomic counter. A real service holds a pool, as in SQLx and Postgres.
  • The lock guard is never held across an .await, which keeps the future Send.
  • Errors are Status values. Tonic writes the grpc-status trailer. Codes such as not_found and invalid_argument play the role that HTTP status codes play in Axum error handling.

Example 6: A unary client

The generated client exposes one method per RPC. It is cheap to clone, and clones share one HTTP/2 connection. Its methods take &mut self, so each task clones the client.

src/bin/client.rs

Rust
use catalog::pb::{
    catalog_client::CatalogClient, CreateProductRequest, GetProductRequest, Product,
    ProductStatus,
};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut client = CatalogClient::connect("http://127.0.0.1:50051").await?;

    let created = client
        .create_product(CreateProductRequest {
            product: Some(Product {
                name: "Notebook".to_owned(),
                price_cents: 450,
                status: ProductStatus::Active as i32,
                tags: vec!["paper".to_owned()],
                description: "A5, 80 pages".to_owned(),
                sale_price_cents: Some(399),
                ..Default::default()
            }),
        })
        .await?
        .into_inner();
    println!("created id={}", created.id);

    let product = client
        .get_product(GetProductRequest { id: created.id })
        .await?
        .into_inner();
    println!(
        "{} costs {}, sale {:?}, status {:?}",
        product.name,
        product.price_cents,
        product.sale_price_cents,
        product.status()
    );

    if let Err(status) = client.get_product(GetProductRequest { id: 99 }).await {
        println!("{:?}: {}", status.code(), status.message());
    }

    let invalid = CreateProductRequest {
        product: Some(Product::default()),
    };
    if let Err(status) = client.create_product(invalid).await {
        println!("{:?}: {}", status.code(), status.message());
    }
    Ok(())
}
  • A bare message converts into a Request. Build Request::new(message) explicitly to set metadata or a timeout.
  • connect opens the connection at once and fails if the server is unreachable. Endpoint::connect_lazy() defers the connection to first use.

Complete example

Prerequisites: Rust, protoc. File tree:

Text
catalog/
├── Cargo.toml
├── build.rs
├── proto/
│   └── catalog.proto
└── src/
    ├── lib.rs
    ├── main.rs
    └── bin/
        └── client.rs

Cargo.toml

TOML
[package]
name = "catalog"
version = "0.1.0"
edition = "2021"

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

[build-dependencies]
tonic-build = "0.13"

build.rs

Rust
fn main() -> Result<(), Box<dyn std::error::Error>> {
    tonic_build::compile_protos("proto/catalog.proto")?;
    Ok(())
}

proto/catalog.proto

PROTOBUF
syntax = "proto3";

package storefront.catalog.v1;

service Catalog {
  rpc GetProduct(GetProductRequest) returns (Product);
  rpc CreateProduct(CreateProductRequest) returns (Product);
}

message GetProductRequest {
  int64 id = 1;
}

message CreateProductRequest {
  Product product = 1;
}

message Product {
  reserved 6;
  reserved "legacy_sku";

  int64 id = 1;
  string name = 2;
  int64 price_cents = 3;
  ProductStatus status = 4;
  repeated string tags = 5;
  string description = 7;
  optional int64 sale_price_cents = 8;
}

enum ProductStatus {
  PRODUCT_STATUS_UNSPECIFIED = 0;
  PRODUCT_STATUS_ACTIVE = 1;
  PRODUCT_STATUS_DISCONTINUED = 2;
}

src/lib.rs

Rust
pub mod pb {
    tonic::include_proto!("storefront.catalog.v1");
}

src/main.rs

Rust
use std::{
    collections::HashMap,
    sync::{
        atomic::{AtomicI64, Ordering},
        Mutex,
    },
};

use catalog::pb::{
    catalog_server::{Catalog, CatalogServer},
    CreateProductRequest, GetProductRequest, Product,
};
use tonic::{transport::Server, Request, Response, Status};

struct CatalogService {
    products: Mutex<HashMap<i64, Product>>,
    next_id: AtomicI64,
}

impl CatalogService {
    fn new() -> Self {
        Self {
            products: Mutex::new(HashMap::new()),
            next_id: AtomicI64::new(1),
        }
    }
}

#[tonic::async_trait]
impl Catalog for CatalogService {
    async fn get_product(
        &self,
        request: Request<GetProductRequest>,
    ) -> Result<Response<Product>, Status> {
        let id = request.into_inner().id;
        let products = self.products.lock().unwrap();
        match products.get(&id) {
            Some(product) => Ok(Response::new(product.clone())),
            None => Err(Status::not_found(format!("product {id} not found"))),
        }
    }

    async fn create_product(
        &self,
        request: Request<CreateProductRequest>,
    ) -> Result<Response<Product>, Status> {
        let mut product = request
            .into_inner()
            .product
            .ok_or_else(|| Status::invalid_argument("product is required"))?;

        if product.name.is_empty() {
            return Err(Status::invalid_argument("name must not be empty"));
        }
        if product.price_cents < 0 {
            return Err(Status::invalid_argument("price must not be negative"));
        }
        if let Some(sale) = product.sale_price_cents {
            if sale >= product.price_cents {
                return Err(Status::invalid_argument("sale price must be below price"));
            }
        }

        product.id = self.next_id.fetch_add(1, Ordering::SeqCst);
        self.products
            .lock()
            .unwrap()
            .insert(product.id, product.clone());
        Ok(Response::new(product))
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr = "127.0.0.1:50051".parse()?;
    println!("catalog listening on {addr}");
    Server::builder()
        .add_service(CatalogServer::new(CatalogService::new()))
        .serve(addr)
        .await?;
    Ok(())
}

src/bin/client.rs

Rust
use catalog::pb::{
    catalog_client::CatalogClient, CreateProductRequest, GetProductRequest, Product,
    ProductStatus,
};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut client = CatalogClient::connect("http://127.0.0.1:50051").await?;

    let created = client
        .create_product(CreateProductRequest {
            product: Some(Product {
                name: "Notebook".to_owned(),
                price_cents: 450,
                status: ProductStatus::Active as i32,
                tags: vec!["paper".to_owned()],
                description: "A5, 80 pages".to_owned(),
                sale_price_cents: Some(399),
                ..Default::default()
            }),
        })
        .await?
        .into_inner();
    println!("created id={}", created.id);

    let product = client
        .get_product(GetProductRequest { id: created.id })
        .await?
        .into_inner();
    println!(
        "{} costs {}, sale {:?}, status {:?}",
        product.name,
        product.price_cents,
        product.sale_price_cents,
        product.status()
    );

    if let Err(status) = client.get_product(GetProductRequest { id: 99 }).await {
        println!("{:?}: {}", status.code(), status.message());
    }

    let invalid = CreateProductRequest {
        product: Some(Product::default()),
    };
    if let Err(status) = client.create_product(invalid).await {
        println!("{:?}: {}", status.code(), status.message());
    }
    Ok(())
}

Run the server in one terminal and the client in another:

Terminal
cargo run --bin catalog
cargo run --bin client

Server output:

Output
catalog listening on 127.0.0.1:50051

Client output:

Output
created id=1
Notebook costs 450, sale Some(399), status Active
NotFound: product 99 not found
InvalidArgument: name must not be empty

curl does not speak gRPC framing. Testing with grpcurl needs server reflection, added in gRPC in production.

Summary

  • gRPC sends protobuf messages over HTTP/2 to paths shaped like /package.Service/Method, and the status arrives in trailers.
  • The .proto file defines messages, enums, and services, and is the contract between teams.
  • Field numbers are the wire identity of a field. Names are not sent.
  • Safe changes: add fields, add methods, reserve removed fields. Unsafe changes: renumber, retype, reuse numbers, remove methods.
  • proto3 scalars have no presence unless marked optional. The first enum value is zero and means "unspecified".
  • tonic-build runs in build.rs, and tonic::include_proto! includes the generated code.
  • A unary handler returns Result<Response<T>, Status>, and Status carries the gRPC code and message.
  • Cloned clients share one connection.