Guide contents

Lesson 21 of 30

Production gRPC: TLS, Health, Load Balancing, and Testing

This lesson adds the production pieces to the Catalog gRPC service from gRPC basics and gRPC streaming: health checks, reflection, TLS, client-side balancing, gRPC-Web, one port shared with REST, and tests. The last example is the complete service.

Project setup

The crate is a library (schema and service) plus several binaries. protoc must be installed for tonic-build. Tonic 0.13 depends on Axum 0.8, which keeps the router types aligned with the rest of the course.

Cargo.toml

TOML
[package]
name = "catalog-service"
version = "0.1.0"
edition = "2021"
default-run = "catalog-service"

[dependencies]
axum = "0.8"
prost = "0.13"
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
tonic = { version = "0.13", features = ["tls-ring"] }
tonic-health = "0.13"
tonic-reflection = "0.13"
tonic-web = "0.13"
tower-http = { version = "0.6", features = ["cors"] }

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

[dev-dependencies]
tokio-stream = { version = "0.1", features = ["net"] }

The files proto/catalog.proto, build.rs, and src/lib.rs (the shared service) are listed in full under Complete example. build.rs also writes a file descriptor set (the compiled schema), which reflection serves in Example 4.

Example 1: Plain server with the health protocol

The gRPC health checking protocol is a standard service, grpc.health.v1.Health, with Check (one answer) and Watch (a stream of changes). The tonic-health crate implements it. Kubernetes can probe it natively with a grpc probe, so the Deployment needs no HTTP endpoint (Docker, Kubernetes and CI).

src/bin/plain_server.rs

Rust
use std::net::SocketAddr;

use catalog_service::{pb::catalog_server::CatalogServer, CatalogService};
use tonic::transport::Server;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr: SocketAddr = std::env::var("LISTEN")
        .unwrap_or_else(|_| "127.0.0.1:50051".into())
        .parse()?;

    let (reporter, health_service) = tonic_health::server::health_reporter();
    reporter
        .set_serving::<CatalogServer<CatalogService>>()
        .await;

    Server::builder()
        .add_service(health_service)
        .add_service(CatalogServer::new(CatalogService::seeded()))
        .serve(addr)
        .await?;
    Ok(())
}
  • health_reporter() returns a handle to change the reported state and the service to register.
  • set_serving::<S>() marks the service named by the generated server type as SERVING. set_not_serving::<S>() marks it NOT_SERVING.
  • Set NOT_SERVING when the database pool is down or during shutdown, so the orchestrator stops routing calls while in-flight calls finish (health and shutdown).

Run it with cargo run --bin plain_server.

Example 2: TLS and mutual TLS

TLS encrypts the connection and lets the client verify the server's certificate. In mutual TLS (mTLS) the client also presents a certificate, and the server verifies it against a certificate authority (CA, the party that signs certificates). Connections without a valid client certificate fail in the handshake, before any handler runs.

Create a CA, a server certificate for localhost, and a client certificate. The extension files make the certificates X.509 version 3, which rustls requires:

Terminal
mkdir certs && cd certs
openssl req -x509 -newkey rsa:2048 -nodes -days 30 -keyout ca.key -out ca.pem -subj "/CN=storefront-ca"
printf "subjectAltName=DNS:localhost\nextendedKeyUsage=serverAuth" > san.ext
printf "extendedKeyUsage=clientAuth" > client.ext
openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr -subj "/CN=localhost"
openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 30 -out server.pem -extfile san.ext
openssl req -newkey rsa:2048 -nodes -keyout client.key -out client.csr -subj "/CN=orders"
openssl x509 -req -in client.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 30 -out client.pem -extfile client.ext
cd ..

src/bin/tls_server.rs

Rust
use catalog_service::{pb::catalog_server::CatalogServer, CatalogService};
use tonic::transport::{Certificate, Identity, Server, ServerTlsConfig};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cert = std::fs::read_to_string("certs/server.pem")?;
    let key = std::fs::read_to_string("certs/server.key")?;
    let client_ca = std::fs::read_to_string("certs/ca.pem")?;

    let tls = ServerTlsConfig::new()
        .identity(Identity::from_pem(cert, key))
        .client_ca_root(Certificate::from_pem(client_ca));

    Server::builder()
        .tls_config(tls)?
        .add_service(CatalogServer::new(CatalogService::seeded()))
        .serve("127.0.0.1:50443".parse()?)
        .await?;
    Ok(())
}

src/bin/tls_client.rs

Rust
use catalog_service::pb::{catalog_client::CatalogClient, GetProductRequest};
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let ca = std::fs::read_to_string("certs/ca.pem")?;
    let cert = std::fs::read_to_string("certs/client.pem")?;
    let key = std::fs::read_to_string("certs/client.key")?;

    let tls = ClientTlsConfig::new()
        .ca_certificate(Certificate::from_pem(ca))
        .domain_name("localhost")
        .identity(Identity::from_pem(cert, key));

    let channel = Channel::from_static("https://localhost:50443")
        .tls_config(tls)?
        .connect()
        .await?;

    let mut client = CatalogClient::new(channel);
    let product = client
        .get_product(GetProductRequest { id: 1 })
        .await?
        .into_inner();
    println!("{} {}", product.name, product.price_cents);
    Ok(())
}

Run cargo run --bin tls_server in one terminal and cargo run --bin tls_client in another. The client prints:

Output
Notebook 450
  • Removing client_ca_root on the server and identity on the client gives plain TLS.
  • domain_name must match a name in the server certificate (localhost through the subjectAltName above). A mismatch fails the handshake.
  • A valid client certificate proves who connected. Which caller may call which method is still an authorization check in code, or a policy in a service mesh (OAuth, JWT and mTLS).
  • Keys and certificates are secrets. Mount them from a secret store, never from the repository.

Example 3: Client-side load balancing

HTTP/2 multiplexes many calls over one long-lived connection. A layer 4 balancer (one that balances TCP connections) picks a backend when the connection opens, so every call on that connection reaches the same instance. Two fixes exist: a layer 7 proxy such as Envoy that balances per call, or a client that holds a connection to each instance and picks one per call. Discovery options are in gateway and service discovery.

Start three instances of Example 1 on different ports:

Terminal
LISTEN=127.0.0.1:50051 cargo run --bin plain_server &
LISTEN=127.0.0.1:50052 cargo run --bin plain_server &
LISTEN=127.0.0.1:50053 cargo run --bin plain_server &

src/bin/balanced_client.rs

Rust
use catalog_service::pb::{catalog_client::CatalogClient, GetProductRequest};
use tonic::transport::{Channel, Endpoint};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let endpoints = [
        "http://127.0.0.1:50051",
        "http://127.0.0.1:50052",
        "http://127.0.0.1:50053",
    ]
    .into_iter()
    .map(Endpoint::from_static);

    let channel = Channel::balance_list(endpoints);
    let mut client = CatalogClient::new(channel);

    for _ in 0..6 {
        client.get_product(GetProductRequest { id: 1 }).await?;
    }
    println!("6 calls succeeded");
    Ok(())
}
Output
6 calls succeeded

Channel::balance_list spreads calls over the listed endpoints, and which instance serves which call is not fixed. The list is static. A changing set uses Channel::balance_channel, which accepts a stream of add and remove changes from a discovery component. In Kubernetes a plain ClusterIP Service is one virtual address and acts like a layer 4 balancer. A headless Service returns pod addresses through DNS, which a client can balance across.

Example 4: Reflection, gRPC-Web, and REST on one port

Three additions go into one Axum process:

  • Reflection lets a client ask the server for its schema, so grpcurl works without .proto files. It exposes the whole API surface, so enable it deliberately on public endpoints.
  • gRPC-Web is a protocol variant that carries trailers inside the body, because browsers cannot read HTTP/2 trailers. tonic-web translates it. Only unary and server-streaming calls work from browsers. Browsers also send a CORS preflight, so CorsLayer from Tower middleware is added.
  • REST beside gRPC: Routes collects Tonic services and converts them into an Axum router, which merges with REST routes. Requests with content-type: application/grpc reach the gRPC paths, and other paths reach the REST handlers.

The REST handler calls CatalogService::find, the same function the gRPC method uses, so the two protocols share one implementation of the rule.

src/main.rs

Rust
use std::net::SocketAddr;

use axum::{
    extract::{Path, State},
    http::StatusCode,
    routing::get,
    Json, Router,
};
use catalog_service::{
    pb::{catalog_server::CatalogServer, FILE_DESCRIPTOR_SET},
    CatalogService,
};
use serde_json::{json, Value};
use tonic::service::Routes;
use tonic_web::GrpcWebLayer;
use tower_http::cors::{Any, CorsLayer};

async fn healthz() -> &'static str {
    "ok"
}

async fn get_product(
    State(catalog): State<CatalogService>,
    Path(id): Path<i64>,
) -> Result<Json<Value>, StatusCode> {
    let product = catalog.find(id).ok_or(StatusCode::NOT_FOUND)?;
    Ok(Json(json!({
        "id": product.id,
        "name": product.name,
        "price_cents": product.price_cents,
    })))
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr: SocketAddr = std::env::var("LISTEN")
        .unwrap_or_else(|_| "127.0.0.1:8080".into())
        .parse()?;
    let catalog = CatalogService::seeded();

    let (reporter, health_service) = tonic_health::server::health_reporter();
    reporter
        .set_serving::<CatalogServer<CatalogService>>()
        .await;

    let reflection = tonic_reflection::server::Builder::configure()
        .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
        .register_encoded_file_descriptor_set(tonic_health::pb::FILE_DESCRIPTOR_SET)
        .build_v1()?;

    let grpc = Routes::new(CatalogServer::new(catalog.clone()))
        .add_service(health_service)
        .add_service(reflection)
        .into_axum_router()
        .layer(GrpcWebLayer::new())
        .layer(
            CorsLayer::new()
                .allow_origin(Any)
                .allow_methods(Any)
                .allow_headers(Any)
                .expose_headers(Any),
        );

    let rest = Router::new()
        .route("/healthz", get(healthz))
        .route("/products/{id}", get(get_product))
        .with_state(catalog);

    let app = rest.merge(grpc);

    let listener = tokio::net::TcpListener::bind(addr).await?;
    axum::serve(listener, app).await?;
    Ok(())
}
  • Routes::new takes the first service, and add_service adds more.
  • The gRPC-Web and CORS layers wrap only the gRPC router, so REST routes are unchanged.
  • The Axum version behind Routes::into_axum_router must match the application's Axum version, or the router types do not match.

Example 5: Tests over a real listener

A handler is an async method, so logic can be tested by calling it directly. To cover encoding and the transport, bind a listener on port 0 (the operating system picks a free port) and connect a client to it.

tests/e2e.rs

Rust
use catalog_service::{
    pb::{catalog_client::CatalogClient, catalog_server::CatalogServer, GetProductRequest},
    CatalogService,
};
use tokio_stream::wrappers::TcpListenerStream;
use tonic::{transport::Server, Code};

async fn start_server() -> String {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    tokio::spawn(async move {
        Server::builder()
            .add_service(CatalogServer::new(CatalogService::seeded()))
            .serve_with_incoming(TcpListenerStream::new(listener))
            .await
            .unwrap();
    });

    format!("http://{addr}")
}

#[tokio::test]
async fn returns_seeded_product() {
    let url = start_server().await;
    let mut client = CatalogClient::connect(url).await.unwrap();

    let product = client
        .get_product(GetProductRequest { id: 1 })
        .await
        .unwrap()
        .into_inner();

    assert_eq!(product.name, "Notebook");
}

#[tokio::test]
async fn unknown_product_is_not_found() {
    let url = start_server().await;
    let mut client = CatalogClient::connect(url).await.unwrap();

    let error = client
        .get_product(GetProductRequest { id: 99 })
        .await
        .unwrap_err();

    assert_eq!(error.code(), Code::NotFound);
}

serve_with_incoming takes a stream of connections, so the test owns the listener and never hard-codes a port. Keep .proto files in one place and run a breaking-change check (such as buf breaking) in CI against the last released schema (service testing).

REST or gRPC

Criterion Favors REST Favors gRPC
Browsers and third-party clients Native Needs gRPC-Web, unary and server streaming only
Internal service to service Works Typed contract, binary payloads
Streaming SSE or WebSocket Native streams
HTTP caching Works for GET Not available
Debugging with basic tools curl Needs reflection and grpcurl

A system often uses both: REST or GraphQL at the edge, gRPC inside. Timeouts, retries, and failure handling from resilience patterns apply to both.

Complete example

The service from Example 4, its test, and the binaries from Examples 1 to 3. default-run makes cargo run start src/main.rs. The certs/ directory comes from the commands in Example 2.

Text
catalog-service/
├── Cargo.toml
├── build.rs
├── proto/
│   └── catalog.proto
├── certs/            (created in Example 2)
├── src/
│   ├── lib.rs
│   ├── main.rs
│   └── bin/
│       ├── plain_server.rs
│       ├── tls_server.rs
│       ├── tls_client.rs
│       └── balanced_client.rs
└── tests/
    └── e2e.rs

Cargo.toml

TOML
[package]
name = "catalog-service"
version = "0.1.0"
edition = "2021"
default-run = "catalog-service"

[dependencies]
axum = "0.8"
prost = "0.13"
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
tonic = { version = "0.13", features = ["tls-ring"] }
tonic-health = "0.13"
tonic-reflection = "0.13"
tonic-web = "0.13"
tower-http = { version = "0.6", features = ["cors"] }

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

[dev-dependencies]
tokio-stream = { version = "0.1", features = ["net"] }

proto/catalog.proto

PROTOBUF
syntax = "proto3";

package storefront.catalog.v1;

message GetProductRequest {
  int64 id = 1;
}

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

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

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

build.rs

The build script also writes a file descriptor set (the compiled schema), which reflection serves later.

Rust
use std::path::PathBuf;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let out_dir = PathBuf::from(std::env::var("OUT_DIR")?);
    tonic_build::configure()
        .file_descriptor_set_path(out_dir.join("catalog_descriptor.bin"))
        .compile_protos(&["proto/catalog.proto"], &["proto"])?;
    Ok(())
}

src/lib.rs

The service holds an in-memory product map. find holds the lookup, so gRPC and REST handlers can share it.

Rust
use std::{collections::HashMap, sync::Arc};

use pb::{catalog_server::Catalog, GetProductRequest, Product, ProductStatus};
use tonic::{Request, Response, Status};

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

    pub const FILE_DESCRIPTOR_SET: &[u8] =
        tonic::include_file_descriptor_set!("catalog_descriptor");
}

#[derive(Clone)]
pub struct CatalogService {
    products: Arc<HashMap<i64, Product>>,
}

impl CatalogService {
    pub fn seeded() -> Self {
        let mut products = HashMap::new();
        products.insert(
            1,
            Product {
                id: 1,
                name: "Notebook".into(),
                price_cents: 450,
                status: ProductStatus::Active as i32,
                tags: vec!["stationery".into()],
            },
        );
        products.insert(
            2,
            Product {
                id: 2,
                name: "Pen".into(),
                price_cents: 120,
                status: ProductStatus::Active as i32,
                tags: vec!["stationery".into(), "writing".into()],
            },
        );
        Self { products: Arc::new(products) }
    }

    pub fn find(&self, id: i64) -> Option<Product> {
        self.products.get(&id).cloned()
    }
}

#[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;
        self.find(id)
            .map(Response::new)
            .ok_or_else(|| Status::not_found(format!("product {id} not found")))
    }
}

src/main.rs

Rust
use std::net::SocketAddr;

use axum::{
    extract::{Path, State},
    http::StatusCode,
    routing::get,
    Json, Router,
};
use catalog_service::{
    pb::{catalog_server::CatalogServer, FILE_DESCRIPTOR_SET},
    CatalogService,
};
use serde_json::{json, Value};
use tonic::service::Routes;
use tonic_web::GrpcWebLayer;
use tower_http::cors::{Any, CorsLayer};

async fn healthz() -> &'static str {
    "ok"
}

async fn get_product(
    State(catalog): State<CatalogService>,
    Path(id): Path<i64>,
) -> Result<Json<Value>, StatusCode> {
    let product = catalog.find(id).ok_or(StatusCode::NOT_FOUND)?;
    Ok(Json(json!({
        "id": product.id,
        "name": product.name,
        "price_cents": product.price_cents,
    })))
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr: SocketAddr = std::env::var("LISTEN")
        .unwrap_or_else(|_| "127.0.0.1:8080".into())
        .parse()?;
    let catalog = CatalogService::seeded();

    let (reporter, health_service) = tonic_health::server::health_reporter();
    reporter
        .set_serving::<CatalogServer<CatalogService>>()
        .await;

    let reflection = tonic_reflection::server::Builder::configure()
        .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
        .register_encoded_file_descriptor_set(tonic_health::pb::FILE_DESCRIPTOR_SET)
        .build_v1()?;

    let grpc = Routes::new(CatalogServer::new(catalog.clone()))
        .add_service(health_service)
        .add_service(reflection)
        .into_axum_router()
        .layer(GrpcWebLayer::new())
        .layer(
            CorsLayer::new()
                .allow_origin(Any)
                .allow_methods(Any)
                .allow_headers(Any)
                .expose_headers(Any),
        );

    let rest = Router::new()
        .route("/healthz", get(healthz))
        .route("/products/{id}", get(get_product))
        .with_state(catalog);

    let app = rest.merge(grpc);

    let listener = tokio::net::TcpListener::bind(addr).await?;
    axum::serve(listener, app).await?;
    Ok(())
}

tests/e2e.rs

Rust
use catalog_service::{
    pb::{catalog_client::CatalogClient, catalog_server::CatalogServer, GetProductRequest},
    CatalogService,
};
use tokio_stream::wrappers::TcpListenerStream;
use tonic::{transport::Server, Code};

async fn start_server() -> String {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    tokio::spawn(async move {
        Server::builder()
            .add_service(CatalogServer::new(CatalogService::seeded()))
            .serve_with_incoming(TcpListenerStream::new(listener))
            .await
            .unwrap();
    });

    format!("http://{addr}")
}

#[tokio::test]
async fn returns_seeded_product() {
    let url = start_server().await;
    let mut client = CatalogClient::connect(url).await.unwrap();

    let product = client
        .get_product(GetProductRequest { id: 1 })
        .await
        .unwrap()
        .into_inner();

    assert_eq!(product.name, "Notebook");
}

#[tokio::test]
async fn unknown_product_is_not_found() {
    let url = start_server().await;
    let mut client = CatalogClient::connect(url).await.unwrap();

    let error = client
        .get_product(GetProductRequest { id: 99 })
        .await
        .unwrap_err();

    assert_eq!(error.code(), Code::NotFound);
}

src/bin/plain_server.rs

Rust
use std::net::SocketAddr;

use catalog_service::{pb::catalog_server::CatalogServer, CatalogService};
use tonic::transport::Server;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr: SocketAddr = std::env::var("LISTEN")
        .unwrap_or_else(|_| "127.0.0.1:50051".into())
        .parse()?;

    let (reporter, health_service) = tonic_health::server::health_reporter();
    reporter
        .set_serving::<CatalogServer<CatalogService>>()
        .await;

    Server::builder()
        .add_service(health_service)
        .add_service(CatalogServer::new(CatalogService::seeded()))
        .serve(addr)
        .await?;
    Ok(())
}

src/bin/tls_server.rs

Rust
use catalog_service::{pb::catalog_server::CatalogServer, CatalogService};
use tonic::transport::{Certificate, Identity, Server, ServerTlsConfig};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cert = std::fs::read_to_string("certs/server.pem")?;
    let key = std::fs::read_to_string("certs/server.key")?;
    let client_ca = std::fs::read_to_string("certs/ca.pem")?;

    let tls = ServerTlsConfig::new()
        .identity(Identity::from_pem(cert, key))
        .client_ca_root(Certificate::from_pem(client_ca));

    Server::builder()
        .tls_config(tls)?
        .add_service(CatalogServer::new(CatalogService::seeded()))
        .serve("127.0.0.1:50443".parse()?)
        .await?;
    Ok(())
}

src/bin/tls_client.rs

Rust
use catalog_service::pb::{catalog_client::CatalogClient, GetProductRequest};
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let ca = std::fs::read_to_string("certs/ca.pem")?;
    let cert = std::fs::read_to_string("certs/client.pem")?;
    let key = std::fs::read_to_string("certs/client.key")?;

    let tls = ClientTlsConfig::new()
        .ca_certificate(Certificate::from_pem(ca))
        .domain_name("localhost")
        .identity(Identity::from_pem(cert, key));

    let channel = Channel::from_static("https://localhost:50443")
        .tls_config(tls)?
        .connect()
        .await?;

    let mut client = CatalogClient::new(channel);
    let product = client
        .get_product(GetProductRequest { id: 1 })
        .await?
        .into_inner();
    println!("{} {}", product.name, product.price_cents);
    Ok(())
}

src/bin/balanced_client.rs

Rust
use catalog_service::pb::{catalog_client::CatalogClient, GetProductRequest};
use tonic::transport::{Channel, Endpoint};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let endpoints = [
        "http://127.0.0.1:50051",
        "http://127.0.0.1:50052",
        "http://127.0.0.1:50053",
    ]
    .into_iter()
    .map(Endpoint::from_static);

    let channel = Channel::balance_list(endpoints);
    let mut client = CatalogClient::new(channel);

    for _ in 0..6 {
        client.get_product(GetProductRequest { id: 1 }).await?;
    }
    println!("6 calls succeeded");
    Ok(())
}

Run

Requires protoc and grpcurl. Run cargo test for the tests and cargo run for the service. The extra binaries run as described in Examples 1 to 3 (cargo run --bin plain_server, and so on). Requests to the service:

Terminal
curl -s http://127.0.0.1:8080/healthz
Output
ok
Terminal
curl -s http://127.0.0.1:8080/products/1
Output
{"id":1,"name":"Notebook","price_cents":450}
Terminal
grpcurl -plaintext -d '{"id": 1}' 127.0.0.1:8080 storefront.catalog.v1.Catalog/GetProduct
Output
{
  "id": "1",
  "name": "Notebook",
  "priceCents": "450",
  "status": "PRODUCT_STATUS_ACTIVE",
  "tags": [
    "stationery"
  ]
}
Terminal
grpcurl -plaintext -d '{"service": "storefront.catalog.v1.Catalog"}' 127.0.0.1:8080 grpc.health.v1.Health/Check
Output
{
  "status": "SERVING"
}
Terminal
grpcurl -plaintext -d '{"id": 99}' 127.0.0.1:8080 storefront.catalog.v1.Catalog/GetProduct
Output
ERROR:
  Code: NotFound
  Message: product 99 not found

grpcurl renders 64-bit integers as JSON strings, as shown.

Summary

  • TLS needs a server identity and a trusted CA on the client. mTLS adds a client identity and a client CA on the server.
  • tonic-health implements the health service. set_serving and set_not_serving change the reported state.
  • tonic-reflection serves the descriptor set that tonic-build writes.
  • Channel::balance_list balances calls across a fixed endpoint list.
  • tonic-web plus CORS lets browsers call unary and server-streaming methods.
  • Routes::into_axum_router merges gRPC services with REST routes.
  • Tests bind 127.0.0.1:0 and use serve_with_incoming.