Skip to content

Recipe 49: Typed RPC Service

What You Build

A typed RPC server using shared-memory FBMU as the transport. Client serializes an EchoRequest into a leased RPC region; the server dispatches it through grafos_rpc::RpcHandler and returns EchoResponse through the same lease-backed transport. No TLS, no HTTP inside the hot path: the lease is the trust boundary.

Source

cookbook/recipe-49-rpc-service/ in the source tree.

The recipe is the host-testable handler plus a real RpcHandler implementation. The handler takes a typed EchoRequest and returns a typed EchoResponse; EchoRpcService is what a grafos_rpc::RpcServer can dispatch.

Core grafOS API Path

The RPC transport is a memory lease plus a client/server pair over fixed request and response regions:

use cookbook_recipe_49_rpc_service::{
EchoRequest, EchoResponse, EchoRpcService, ECHO_METHOD_ID,
};
use grafos_rpc::{RpcClient, RpcServer};
use grafos_std::mem::MemBuilder;
let lease = MemBuilder::new()
.min_bytes(64 * 1024)
.lease_secs(300)
.acquire()?;
let mut client = RpcClient::new(&lease);
let server = RpcServer::new(&lease);
// The server side runs this from its event loop.
let handled = server.poll_once(&EchoRpcService)?;
// The client side calls with the same typed request/response structs.
let response: EchoResponse = client.call(
ECHO_METHOD_ID,
&EchoRequest::Reverse {
message: "abc".into(),
},
)?;
# let _ = (handled, response);
# Ok::<(), grafos_std::FabricError>(())

In a live service, the RpcClient::call and RpcServer::poll_once calls run on different tasklets or event-loop turns over the same lease-backed arena. The recipe crate keeps the handler host-testable, but the hot path is still MemBuilder to RpcServer/RpcClient to RpcHandler.

#[serde(rename_all = "snake_case")]
pub enum EchoRequest {
Ping,
Echo { message: String },
Reverse { message: String },
}
pub fn handle(req: EchoRequest) -> EchoResponse {
match req {
EchoRequest::Ping => EchoResponse::Pong,
EchoRequest::Echo { message } => {
if message.len() > 1024 {
EchoResponse::Error { reason: "message_too_large".into() }
} else {
EchoResponse::Echoed { message }
}
}
EchoRequest::Reverse { message } => {
EchoResponse::Reversed { message: message.chars().rev().collect() }
}
}
}
pub struct EchoRpcService;
impl RpcHandler for EchoRpcService {
fn handle(&self, method_id: u32, payload: &[u8]) -> FabricResult<Vec<u8>> {
if method_id != ECHO_METHOD_ID {
return Err(FabricError::Unsupported);
}
let request: EchoRequest =
postcard::from_bytes(payload).map_err(|_| FabricError::IoError(-200))?;
postcard::to_allocvec(&handle(request)).map_err(|_| FabricError::IoError(-201))
}
}

What’s interesting

  1. One typed request enum. EchoRequest uses serde’s standard enum representation so JSON tasklet input and postcard RPC payloads share the same compiled type.
  2. Errors are values. EchoResponse::Error { reason } is part of the response enum, not an out-of-band error path. The client deserializes one variant or another; there’s no “is the response valid?” check separate from “what response is it?”
  3. Bounded request sizes. Echo rejects messages over 1024 bytes; the cap is part of the API contract, not a runtime fluke.
  4. Real RPC path. EchoRpcService implements grafos_rpc::RpcHandler; a RpcServer can poll a lease and dispatch requests to it.

Failure Behavior

  • Invalid JSON on the tasklet path returns EchoResponse::Error.
  • Unknown RPC method ids return FabricError::Unsupported.
  • Malformed postcard payloads return FabricError::IoError(-200).
  • Oversized echo messages return the typed response value EchoResponse::Error { reason: "message_too_large" }.

Run And Verify

Terminal window
cargo test -p cookbook-recipe-49-rpc-service

Expected: the tests cover ping, echo, reverse, size rejection, invalid JSON, and RpcHandler dispatch.

Adapt It

Give each method a stable u32 method id, keep request and response enums versioned, and keep payload caps in the handler so callers get typed refusals before they fill the lease-backed transport.