Core Concepts
gRPC Guide
New to acton-service?
Start with the homepage to understand what acton-service is, then explore Core Concepts for foundational explanations. See Dual HTTP+gRPC for protocol multiplexing basics. Check the Glossary for technical term definitions.
Complete guide to implementing gRPC services with acton-service, including protocol buffer setup, code generation, service implementation, and production features.
Overview
acton-service provides first-class gRPC support with:
- Automatic protocol buffers compilation via build utilities
- Middleware parity - same middleware features as HTTP (auth, tracing, rate limiting)
- Single or dual-port deployment - run gRPC+HTTP on one port or separate them
- Health checks and reflection - standard gRPC features built-in
- Type-safe service definitions - compile-time verification
Quick Start
1. Project Structure
my-service/
├── Cargo.toml
├── build.rs # Protocol buffer compilation
├── proto/ # .proto files (convention)
│ └── my_service.proto
└── src/
└── main.rs
2. Enable gRPC Feature
# Cargo.toml
[dependencies]
3. Create Protocol Buffer Definition
// proto/my_service.proto
syntax = "proto3";
package myservice.v1;
service MyService {
rpc GetUser(GetUserRequest) returns (UserResponse);
rpc ListUsers(ListUsersRequest) returns (stream UserResponse);
}
message GetUserRequest {
int64 user_id = 1;
}
message ListUsersRequest {
int32 page_size = 1;
string page_token = 2;
}
message UserResponse {
int64 id = 1;
string name = 2;
string email = 3;
}
4. Setup build.rs
// build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Automatically compiles all .proto files in proto/ directory
acton_service::build_utils::compile_service_protos()?;
Ok(())
}
5. Implement Service
// src/main.rs
use acton_service::prelude::*;
use tonic::{Request, Response, Status};
// Include generated protobuf code
pub mod myservice {
tonic::include_proto!("myservice.v1");
pub const FILE_DESCRIPTOR_SET: &[u8] =
tonic::include_file_descriptor_set!("my_service_descriptor");
}
use myservice::{
my_service_server::{MyService, MyServiceServer},
GetUserRequest, UserResponse,
};
// Service implementation
#[derive(Default)]
struct MyServiceImpl {}
#[tonic::async_trait]
impl MyService for MyServiceImpl {
async fn get_user(
&self,
request: Request<GetUserRequest>,
) -> Result<Response<UserResponse>, Status> {
let user_id = request.into_inner().user_id;
// Your business logic here
let user = UserResponse {
id: user_id,
name: "John Doe".to_string(),
email: "john@example.com".to_string(),
};
Ok(Response::new(user))
}
}
#[tokio::main]
async fn main() -> Result<()> {
// Create gRPC service
let grpc_service = MyServiceServer::new(MyServiceImpl::default());
// Serve on single port (HTTP + gRPC multiplexed)
ServiceBuilder::new()
.with_grpc_service(grpc_service)
.build()
.serve()
.await
}
6. Build and Run
cargo build --features grpc
cargo run
# Test with grpcurl
grpcurl -plaintext -d '{"user_id":123}' \
localhost:8080 myservice.v1.MyService/GetUser
Protocol Buffer Compilation
Build Utilities
acton-service provides three approaches for compiling protocol buffers:
1. Convention-Based (Recommended)
Uses default proto/ directory:
// build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
acton_service::build_utils::compile_service_protos()?;
Ok(())
}
Directory structure:
proto/
├── users.proto
├── orders.proto
└── common/
└── types.proto
All .proto files are discovered recursively and compiled together.
2. Environment-Configured
Override proto location at build time:
# Use custom directory
ACTON_PROTO_DIR=../shared/protos cargo build
# Permanent override in .cargo/config.toml
[env]
ACTON_PROTO_DIR = "../shared/protos"
// build.rs - same code, respects ACTON_PROTO_DIR
fn main() -> Result<(), Box<dyn std::error::Error>> {
acton_service::build_utils::compile_service_protos()?;
Ok(())
}
3. Explicit Directory
Specify directory in code:
// build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
acton_service::build_utils::compile_protos_from_dir("my-protos")?;
Ok(())
}
4. Advanced: Specific Files
For fine-grained control:
// build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
acton_service::build_utils::compile_specific_protos(
&["proto/orders.proto", "proto/users.proto"],
&["proto"], // Include directories
"my_descriptor.bin"
)?;
Ok(())
}
What Gets Generated
During cargo build, proto compilation generates:
- Rust types - message structs and service traits in
OUT_DIR - File descriptor set -
{package_name}_descriptor.binfor reflection - Build warnings - lists which protos were compiled
Example output:
warning: Using proto directory: proto
warning: Compiling 3 proto files from proto
warning: - proto/users.proto
warning: - proto/orders.proto
warning: - proto/common/types.proto
warning: Generated descriptor: target/debug/build/.../my_service_descriptor.bin
Including Generated Code
Basic Include
// Include generated protobuf types
pub mod myservice {
tonic::include_proto!("myservice.v1");
}
use myservice::{
my_service_server::{MyService, MyServiceServer},
my_service_client::MyServiceClient,
GetUserRequest, UserResponse,
};
The package name in your .proto file determines the module path:
package myservice.v1; // → tonic::include_proto!("myservice.v1")
package orders.api; // → tonic::include_proto!("orders.api")
Including File Descriptor Set
For gRPC reflection (required by grpcurl, gRPC UI tools):
pub mod myservice {
tonic::include_proto!("myservice.v1");
// File descriptor set (package name with underscores and _descriptor suffix)
pub const FILE_DESCRIPTOR_SET: &[u8] =
tonic::include_file_descriptor_set!("my_service_descriptor");
}
Naming convention:
- Package:
myservice.v1→ Descriptor:my_service_descriptor - Package:
orders.api→ Descriptor:orders_descriptor - Rule: Replace dots with underscores, add
_descriptor
Service Implementation
Basic Service
use tonic::{Request, Response, Status};
#[derive(Default)]
struct MyServiceImpl {}
#[tonic::async_trait]
impl MyService for MyServiceImpl {
async fn get_user(
&self,
request: Request<GetUserRequest>,
) -> Result<Response<UserResponse>, Status> {
// Extract request
let req = request.into_inner();
// Business logic
let user = fetch_user_from_db(req.user_id).await
.map_err(|e| Status::internal(format!("Database error: {}", e)))?;
// Build response
let response = UserResponse {
id: user.id,
name: user.name,
email: user.email,
};
Ok(Response::new(response))
}
}
With Shared State
use std::sync::Arc;
use sqlx::PgPool;
struct MyServiceImpl {
db: Arc<PgPool>,
}
impl MyServiceImpl {
fn new(db: Arc<PgPool>) -> Self {
Self { db }
}
}
#[tonic::async_trait]
impl MyService for MyServiceImpl {
async fn get_user(
&self,
request: Request<GetUserRequest>,
) -> Result<Response<UserResponse>, Status> {
let user_id = request.into_inner().user_id;
// Use shared database pool
let user = sqlx::query_as!(
User,
"SELECT id, name, email FROM users WHERE id = $1",
user_id
)
.fetch_one(&*self.db)
.await
.map_err(|e| Status::not_found(format!("User not found: {}", e)))?;
Ok(Response::new(UserResponse {
id: user.id,
name: user.name,
email: user.email,
}))
}
}
// In main():
let db = Arc::new(get_database_pool().await?);
let grpc_service = MyServiceServer::new(MyServiceImpl::new(db));
Error Handling
Use tonic Status for errors:
use tonic::{Code, Status};
async fn get_user(&self, request: Request<GetUserRequest>)
-> Result<Response<UserResponse>, Status>
{
let user_id = request.into_inner().user_id;
// Input validation
if user_id <= 0 {
return Err(Status::invalid_argument("user_id must be positive"));
}
// Business logic with error mapping
let user = fetch_user(user_id).await
.map_err(|e| match e {
DbError::NotFound => Status::not_found("User not found"),
DbError::ConnectionFailed => Status::unavailable("Database unavailable"),
_ => Status::internal(format!("Internal error: {}", e)),
})?;
Ok(Response::new(user))
}
Common status codes:
Code::InvalidArgument- Bad inputCode::NotFound- Resource doesn't existCode::PermissionDenied- No accessCode::Unauthenticated- Not logged inCode::Unavailable- Service temporarily downCode::Internal- Unexpected server error
Streaming
Server Streaming
Service sends multiple responses:
service UserService {
rpc ListUsers(ListUsersRequest) returns (stream UserResponse);
}
use tokio_stream::{Stream, StreamExt};
use std::pin::Pin;
type UserStream = Pin<Box<dyn Stream<Item = Result<UserResponse, Status>> + Send>>;
#[tonic::async_trait]
impl UserService for UserServiceImpl {
type ListUsersStream = UserStream;
async fn list_users(
&self,
request: Request<ListUsersRequest>,
) -> Result<Response<Self::ListUsersStream>, Status> {
let page_size = request.into_inner().page_size;
let stream = async_stream::try_stream! {
let mut users = fetch_users_paginated(page_size).await?;
while let Some(user) = users.next().await {
yield UserResponse {
id: user.id,
name: user.name,
email: user.email,
};
}
};
Ok(Response::new(Box::pin(stream) as Self::ListUsersStream))
}
}
Client Streaming
Client sends multiple requests:
service BatchService {
rpc BatchCreateUsers(stream CreateUserRequest) returns (BatchResponse);
}
#[tonic::async_trait]
impl BatchService for BatchServiceImpl {
async fn batch_create_users(
&self,
request: Request<tonic::Streaming<CreateUserRequest>>,
) -> Result<Response<BatchResponse>, Status> {
let mut stream = request.into_inner();
let mut created_count = 0;
while let Some(user_req) = stream.message().await? {
create_user(user_req).await?;
created_count += 1;
}
Ok(Response::new(BatchResponse { created_count }))
}
}
Bidirectional Streaming
Both send multiple messages:
service ChatService {
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
#[tonic::async_trait]
impl ChatService for ChatServiceImpl {
type ChatStream = UserStream;
async fn chat(
&self,
request: Request<tonic::Streaming<ChatMessage>>,
) -> Result<Response<Self::ChatStream>, Status> {
let mut in_stream = request.into_inner();
let out_stream = async_stream::try_stream! {
while let Some(msg) = in_stream.message().await? {
// Process and respond to each message
let response = process_message(msg).await?;
yield response;
}
};
Ok(Response::new(Box::pin(out_stream) as Self::ChatStream))
}
}
Middleware and Interceptors
acton-service provides gRPC middleware with parity to HTTP features.
Request ID Propagation
Automatically adds unique request IDs:
use acton_service::grpc::interceptors::request_id_interceptor;
let service = MyServiceServer::with_interceptor(
service_impl,
request_id_interceptor
);
Access in service:
use acton_service::grpc::RequestIdExtension;
async fn get_user(&self, request: Request<GetUserRequest>)
-> Result<Response<UserResponse>, Status>
{
// Get request ID from extensions
if let Some(request_id) = request.extensions().get::<RequestIdExtension>() {
tracing::info!(request_id = %request_id.0, "Processing request");
}
// ... business logic
}
JWT Authentication
Validate JWT tokens in gRPC requests:
use acton_service::grpc::interceptors::jwt_auth_interceptor;
use acton_service::middleware::JwtAuth;
use std::sync::Arc;
// Create JWT validator
let jwt_auth = Arc::new(JwtAuth::new(&config.jwt)?);
// Apply to service
let service = MyServiceServer::with_interceptor(
service_impl,
move |req| jwt_auth_interceptor(jwt_auth.clone())(req)
);
Tokens must be in metadata:
# grpcurl with JWT
grpcurl -H "authorization: Bearer <token>" \
-plaintext localhost:8080 myservice.v1.MyService/GetUser
Access claims in service:
use acton_service::middleware::Claims;
async fn get_user(&self, request: Request<GetUserRequest>)
-> Result<Response<UserResponse>, Status>
{
// Extract claims from extensions
let claims = request.extensions().get::<Claims>()
.ok_or_else(|| Status::unauthenticated("Missing claims"))?;
let user_id = claims.sub.parse::<i64>()
.map_err(|_| Status::invalid_argument("Invalid user ID"))?;
// ... business logic with authenticated user
}
Tracing
OpenTelemetry tracing for gRPC:
use acton_service::grpc::middleware::GrpcTracingLayer;
use tonic::transport::Server;
Server::builder()
.layer(GrpcTracingLayer) // Add tracing
.add_service(my_service)
.serve(addr)
.await?;
Or use acton-service's ServiceBuilder for automatic tracing:
ServiceBuilder::new()
.with_grpc_service(my_service) // Tracing applied automatically
.build()
.serve()
.await?;
Rate Limiting
Limit gRPC request rates:
use acton_service::grpc::middleware::GrpcRateLimitLayer;
use governor::{Quota, RateLimiter};
use std::num::NonZeroU32;
let quota = Quota::per_second(NonZeroU32::new(100).unwrap());
let limiter = Arc::new(RateLimiter::direct(quota));
Server::builder()
.layer(GrpcRateLimitLayer::new(limiter))
.add_service(my_service)
.serve(addr)
.await?;
Combining Interceptors
Chain multiple interceptors:
let service = MyServiceServer::with_interceptor(
service_impl,
move |mut req| {
// Request ID
req = request_id_interceptor(req)?;
// JWT auth
req = jwt_auth_interceptor(jwt_auth.clone())(req)?;
// Custom logging
tracing::info!("gRPC request received");
Ok(req)
}
);
Health Checks
Standard gRPC Health
acton-service implements grpc.health.v1.Health protocol:
use acton_service::grpc::HealthService;
let health_service = HealthService::new();
ServiceBuilder::new()
.with_grpc_service(health_service) // Standard gRPC health
.with_grpc_service(my_service)
.build()
.serve()
.await?;
Check health with grpcurl:
grpcurl -plaintext localhost:8080 grpc.health.v1.Health/Check
Kubernetes Integration
Use gRPC health for readiness probes:
readinessProbe:
grpc:
port: 8080
service: grpc.health.v1.Health
initialDelaySeconds: 5
periodSeconds: 10
gRPC Reflection
Enable service discovery for dynamic clients (grpcurl, gRPC UI):
use tonic_reflection::server::Builder;
// Build reflection service
let reflection_service = Builder::configure()
.register_encoded_file_descriptor_set(myservice::FILE_DESCRIPTOR_SET)
.build()?;
ServiceBuilder::new()
.with_grpc_service(reflection_service)
.with_grpc_service(my_service)
.build()
.serve()
.await?;
Now you can use grpcurl without .proto files:
# List services
grpcurl -plaintext localhost:8080 list
# List methods
grpcurl -plaintext localhost:8080 list myservice.v1.MyService
# Describe method
grpcurl -plaintext localhost:8080 describe myservice.v1.MyService.GetUser
# Call method (without .proto file!)
grpcurl -plaintext -d '{"user_id":123}' \
localhost:8080 myservice.v1.MyService/GetUser
Deployment Modes
Single Port (HTTP + gRPC)
Default mode - automatic protocol detection:
ServiceBuilder::new()
.with_routes(http_routes) // HTTP routes
.with_grpc_service(grpc_service) // gRPC service
.build()
.serve() // Single port (8080)
.await?;
Both protocols work on localhost:8080:
# HTTP
curl http://localhost:8080/api/v1/users
# gRPC
grpcurl -plaintext localhost:8080 myservice.v1.MyService/GetUser
Separate Ports
Run gRPC on dedicated port:
# config.toml
[grpc]
enabled = true
use_separate_port = true
port = 9090
ServiceBuilder::new()
.with_routes(http_routes) // Port 8080
.with_grpc_service(grpc_service) // Port 9090
.build()
.serve()
.await?;
gRPC Only
Skip HTTP entirely:
ServiceBuilder::new()
.with_grpc_service(grpc_service)
.build()
.serve()
.await?;
Configuration
# config.toml
[grpc]
# Enable gRPC server
enabled = true
# Use separate port for gRPC (false = single-port multiplexing)
use_separate_port = false
# gRPC port (only used if use_separate_port = true)
port = 9090
# Enable gRPC reflection
reflection_enabled = true
# Enable gRPC health check service
health_check_enabled = true
# Maximum message size in MB
max_message_size_mb = 4
# Connection timeout in seconds
connection_timeout_secs = 10
# Request timeout in seconds
timeout_secs = 30
Access in code:
let config = Config::load()?;
if let Some(grpc_config) = &config.grpc {
let max_size = grpc_config.max_message_size_bytes();
let timeout = grpc_config.timeout();
// ...
}
Complete Example
Full working example combining all features:
use acton_service::prelude::*;
use acton_service::grpc::{interceptors::*, middleware::*, HealthService};
use std::sync::Arc;
use tonic::{Request, Response, Status, transport::Server};
use tonic_reflection::server::Builder as ReflectionBuilder;
// Include generated code
pub mod myservice {
tonic::include_proto!("myservice.v1");
pub const FILE_DESCRIPTOR_SET: &[u8] =
tonic::include_file_descriptor_set!("my_service_descriptor");
}
use myservice::{
my_service_server::{MyService, MyServiceServer},
GetUserRequest, UserResponse,
};
// Service implementation with state
struct MyServiceImpl {
db: Arc<sqlx::PgPool>,
}
#[tonic::async_trait]
impl MyService for MyServiceImpl {
async fn get_user(
&self,
request: Request<GetUserRequest>,
) -> Result<Response<UserResponse>, Status> {
// Extract request ID
if let Some(req_id) = request.extensions().get::<RequestIdExtension>() {
tracing::info!(request_id = %req_id.0, "Processing GetUser");
}
// Extract JWT claims
let claims = request.extensions().get::<Claims>()
.ok_or_else(|| Status::unauthenticated("Missing auth"))?;
let user_id = request.into_inner().user_id;
// Fetch from database
let user = sqlx::query_as!(
User,
"SELECT id, name, email FROM users WHERE id = $1",
user_id
)
.fetch_one(&*self.db)
.await
.map_err(|_| Status::not_found("User not found"))?;
Ok(Response::new(UserResponse {
id: user.id,
name: user.name,
email: user.email,
}))
}
}
#[tokio::main]
async fn main() -> Result<()> {
// Load config
let config = Config::load()?;
// Setup database
let db = Arc::new(get_db_pool(&config).await?);
// Setup JWT auth
let jwt_auth = Arc::new(JwtAuth::new(&config.jwt)?);
// Create service with interceptors
let service_impl = MyServiceImpl { db };
let grpc_service = MyServiceServer::with_interceptor(
service_impl,
move |req| {
let req = request_id_interceptor(req)?;
jwt_auth_interceptor(jwt_auth.clone())(req)
}
);
// Setup reflection
let reflection = ReflectionBuilder::configure()
.register_encoded_file_descriptor_set(myservice::FILE_DESCRIPTOR_SET)
.build()?;
// Setup health
let health = HealthService::new();
// Serve with acton-service
ServiceBuilder::new()
.with_grpc_service(health)
.with_grpc_service(reflection)
.with_grpc_service(grpc_service)
.build()
.serve()
.await
}
Troubleshooting
Proto Files Not Found
error: No .proto files found in directory: proto
Solution: Ensure proto/ directory exists with .proto files, or set ACTON_PROTO_DIR.
Descriptor Not Found
error: couldn't find `my_service_descriptor` in `OUT_DIR`
Solution: Descriptor name must match package name pattern:
- Package
myservice.v1→ descriptormy_service_descriptor - Replace dots with underscores, add
_descriptorsuffix
Build Fails Without DATABASE_URL
SQLx compile-time verification requires database during build.
Solution: Use SQLx offline mode:
# Generate sqlx-data.json
cargo sqlx prepare
# Build without database
cargo build
gRPC Service Not Responding
Check that:
grpcfeature is enabled in Cargo.toml- Service is registered with
ServiceBuilder::with_grpc_service() - Correct port (default 8080, or check config)
- Protocol detection working (use separate ports to debug)
Next Steps
- Review Dual HTTP+gRPC for protocol multiplexing
- Explore Observability for gRPC tracing and metrics
- Check Examples for complete working implementations
- See Configuration for gRPC-specific settings
- Read Glossary for protocol buffer and gRPC term definitions