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
[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
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
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.
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-statustrailer. Browsers cannot read trailers fromfetch, 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)
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;
}packagenamespaces 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.
repeatedmarks a list.- The first enum value must be
0. A field that was never set decodes as the zero value, so0means "unspecified" and not a real state. - Proto3 scalars have no "absent" state: an unset
int64reads as0. Theoptionalkeyword adds presence tracking and generatesOption<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)
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
i32in the struct. Cast withProductStatus::Active as i32to set one. The generated getterproduct.status()returns the enum. - A field of message type, such as
CreateProductRequest.product, isOption<Product>. Theoptional int64field isOption<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
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 containasync fnmethods.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 aMutexand 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 futureSend. - Errors are
Statusvalues. Tonic writes thegrpc-statustrailer. Codes such asnot_foundandinvalid_argumentplay 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
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. BuildRequest::new(message)explicitly to set metadata or a timeout. connectopens 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:
catalog/
├── Cargo.toml
├── build.rs
├── proto/
│ └── catalog.proto
└── src/
├── lib.rs
├── main.rs
└── bin/
└── client.rsCargo.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
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::compile_protos("proto/catalog.proto")?;
Ok(())
}proto/catalog.proto
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
pub mod pb {
tonic::include_proto!("storefront.catalog.v1");
}src/main.rs
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
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:
cargo run --bin catalog
cargo run --bin clientServer output:
catalog listening on 127.0.0.1:50051Client output:
created id=1
Notebook costs 450, sale Some(399), status Active
NotFound: product 99 not found
InvalidArgument: name must not be emptycurl 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
.protofile 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-buildruns inbuild.rs, andtonic::include_proto!includes the generated code.- A unary handler returns
Result<Response<T>, Status>, andStatuscarries the gRPC code and message. - Cloned clients share one connection.
