Services
Services implement a request-response communication pattern between nodes. A node exposes a service to handle incoming requests, and other nodes consume that service to send requests and receive responses.
Use services for operations that need a result, such as querying a node’s state, toggling a feature, or triggering a one-time computation.
Exposing a service
Section titled “Exposing a service”A node that handles service requests declares its services under interfaces.services.exposes in its peppy.json5.
Each service defines a name, an optional request_message_format, and an optional response_message_format:
{ peppy_schema: "node_v1", manifest: { name: "uvc_camera", tag: "v1", }, interfaces: { services: { exposes: [ { name: "enable_camera", request_message_format: { enable: "bool", }, response_message_format: { enabled: "bool", error_msg: { $type: "string", $optional: true }, }, }, { // A service without a request body; the caller just needs the response. name: "get_camera_info", response_message_format: { card_type: "string", size: "string", interval: "string" }, }, ], }, }, execution: { language: "rust", build_cmd: ["cargo", "build", "--release"], run_cmd: ["./target/release/uvc_camera"] },}Both request_message_format and response_message_format are optional.
A service with no request body acts like a simple getter, and a service with no response body acts like a fire-and-forget trigger.
Handling requests
Section titled “Handling requests”After running peppy node sync, the code generator creates a module for each exposed service under peppygen::exposed_services.
Use handle_next_request to process incoming requests.
Each request is handled in a separate task so that the main async block is not blocked:
use peppygen::exposed_services::enable_camera;use peppygen::{NodeBuilder, Parameters, Result};
fn main() -> Result<()> { NodeBuilder::new().run(|_args: Parameters, node_runner| async move { tokio::spawn(async move { enable_camera::handle_next_request( &node_runner, |request| -> Result<enable_camera::Response> { println!( "enable_camera request from {}: enable = {}", request.instance_id, request.data.enable ); Ok(enable_camera::Response::new( request.data.enable, Some("ok".to_owned()), )) }, ) .await });
Ok(()) })}The request argument contains:
instance_id: the consumer instance that sent the request, read straight from the request context. The producer is binding-agnostic; it doesn’t know which slot on the consumer this call is heading to.data: the deserialized request payload (only present when arequest_message_formatis defined).
handle_next_request processes a single request and returns.
To serve requests continuously, call it in a loop inside a spawned task:
tokio::spawn(async move { loop { let _ = enable_camera::handle_next_request(&node_runner, |request| { // ... }) .await; }});For a service without a request body, the handler receives a Request with only the instance_id:
use peppygen::exposed_services::get_camera_info;
tokio::spawn(async move { get_camera_info::handle_next_request( &node_runner, |request| -> Result<get_camera_info::Response> { println!("get_camera_info request from {}", request.instance_id); Ok(get_camera_info::Response::new( "UVC Webcam".to_owned(), "1920x1080".to_owned(), "30fps".to_owned(), )) }, ) .await});A producer exposes its service exactly once and serves any consumer that calls it. Starting the producer before any consumer (or after them) is equally valid.
Consuming a service
Section titled “Consuming a service”A node that calls a service declares what it consumes under interfaces.services.consumes.
Dependencies are declared in manifest.depends_on and referenced by link_id in the interface:
{ peppy_schema: "node_v1", manifest: { name: "robot_brain", tag: "v1", depends_on: { nodes: [ { name: "uvc_camera", tag: "v1", link_id: "uvc_camera" }, ] }, }, interfaces: { services: { consumes: [ { link_id: "uvc_camera", // References depends_on.nodes[].link_id name: "enable_camera", // Service name on that node }, ], }, }, execution: { language: "rust", build_cmd: ["cargo", "build", "--release"], run_cmd: ["./target/release/robot_brain"] },}Calling a service
Section titled “Calling a service”The code generator creates a module for each consumed service under peppygen::consumed_services.
Use poll to send a request and wait for a response. The signature is the same for pinned and from_any: true slots: there is no call-site targeting argument; the slot’s binding (or lack of one) determines which producer handles the call.
use peppygen::consumed_services::uvc_camera_enable_camera;use peppygen::{NodeBuilder, Parameters, Result};use std::time::Duration;
fn main() -> Result<()> { NodeBuilder::new().run(|_args: Parameters, node_runner| async move { let request = uvc_camera_enable_camera::Request::new(true); let response = uvc_camera_enable_camera::poll( &node_runner, Duration::from_secs(5), // timeout request, ) .await?;
println!( "enable_camera result: instance={} enabled={} error={}", response.instance_id, response.data.enabled, response.data.error_msg.as_deref().unwrap_or("<none>"), );
Ok(()) })}The response contains:
instance_id: the producer instance that handled the request, read from the response context.data: the deserialized response payload.
For a service without a request body, poll simply takes no request:
use peppygen::consumed_services::uvc_camera_get_camera_info;
let response = uvc_camera_get_camera_info::poll( &node_runner, Duration::from_secs(5),).await?;
println!("Camera: {} {}", response.data.card_type, response.data.size);Bindings and routing
Section titled “Bindings and routing”Routing for services is the same consumer-side model used by topics. A binding KEY: VALUE creates a private channel from producer instance VALUE to one of the consumer’s declared slots; the producer itself doesn’t know or care about bindings.
Each slot in depends_on is either:
- Pinned (the default, when
from_anyis absent orfalse): the slot must be bound. The generatedpollresolves the bound producer’sinstance_idfrom the binding and sends the wire request directly to that one producer. The validator rejects a pinned-unbound slot before the stack starts. from_any: true: when the slot is bound, the generatedpollresolves the binding and addresses the bound producer’sinstance_iddirectly, exactly like a pinned slot. When the slot is left unbound, the call falls back to wildcard discovery: the framework runs a probe to every producer matching the slot’s(name, tag), pins the first responder, and delivers the real request only to that producer. Bindings constrain which producers the wildcard discovery may consider.
In a launcher / stack config:
{ source: { local: "./consumer" }, instances: [{ instance_id: "my_consumer", bindings: { uvc_camera: "my-camera-instance" }, }],}or, when launching a single node during development:
peppy node run --bind uvc_camera@my-camera-instance .Per-call precedence
Section titled “Per-call precedence”When several sibling slots of the same (name, tag) exist on a consumer (say a pinned wrist_left_camera and a from_any extra_cam for (depth_camera, v1)), the framework applies the same precedence rule that routes topics:
- If pinned slots’ bindings name the producer the call resolves to → the producer’s call lands on that pinned slot.
- Otherwise, if a
from_anyslot exists for the producer’s(name, tag)and is either explicitly bound to that producer or unbound (wildcard fallback) → the call lands on thefrom_anyslot. - Otherwise the slot has no matching binding and the call cannot be resolved.
Worked example: openarm01_backbone
Section titled “Worked example: openarm01_backbone”A consumer that wires two specific depth cameras to dedicated wrist slots and lets a third slot reach any other depth camera:
{ manifest: { name: "openarm01_backbone", tag: "v1", depends_on: { interfaces: [ { name: "depth_camera", tag: "v1", link_id: "wrist_left_camera" }, { name: "depth_camera", tag: "v1", link_id: "wrist_right_camera" }, { name: "depth_camera", tag: "v1", link_id: "extra_cam", from_any: true }, ], }, }, // ...}{ deployments: [ { source: { name: "depth_camera:v1" }, instances: [ { instance_id: "left_cam" }, { instance_id: "right_cam" }, { instance_id: "ceiling_cam" }, ]}, { source: { name: "openarm01_backbone:v1" }, instances: [ { instance_id: "backbone_inst_1", bindings: { wrist_left_camera: "left_cam", wrist_right_camera: "right_cam", }}, ]}, ],}Four contract statements follow from this manifest:
wrist_left_camera_<service>::poll(...)reachesleft_cam(pinned-bound).wrist_right_camera_<service>::poll(...)reachesright_cam(pinned-bound).extra_cam_<service>::poll(...)reachesceiling_camvia the unboundfrom_anywildcard (the discover-then-pin probe lands there because the pinned slots already claim the other producers).- If
extra_camhad been bound (e.g.extra_cam: "ceiling_cam"), discovery would be restricted to the bound set instead of every unclaimed producer.
Why discover-then-pin?
Section titled “Why discover-then-pin?”The underlying Zenoh transport broadcasts a wildcard service query to every matching producer (QueryTarget::All). Without discovery, every producer’s user handler would run, even though the consumer only ever consumes the first reply. For idempotent reads that wastes work; for state-changing services it can cause real-world side effects on producers the consumer never intended to reach. The discover-then-pin step uses a probe payload that is auto-handled by the framework before the user handler is invoked, so non-winning producers stay idle.
The cost is one additional round-trip on wildcard calls (typically a few milliseconds). Consumers whose slot is pinned by a binding skip discovery entirely and pay no overhead. If a tighter response_timeout is passed, discovery is capped at that budget rather than the framework’s default probe timeout, so tight-budget failure modes still surface quickly.
If the discovered producer dies between the probe and the real request, the call surfaces ServiceUnreachable and the caller can retry; the next attempt will re-discover and pin to a different producer if one is available.
Validator rules
Section titled “Validator rules”The launcher validator runs these checks before the stack starts:
- Pinned-unbound is a hard error. Every pinned slot in
depends_onmust have a binding whoseKEYequals the slot’slink_id. - A
KEYthat matches a pinned slot’slink_idbinds that slot toVALUE. - Free-form
KEYs are allowed forfrom_anyslots. A binding whoseKEYdoesn’t match a pinnedlink_idis accepted as long as somefrom_anyslot exists forVALUE’s(name, tag); multiple such bindings on the samefrom_anyslot accumulate. - A
KEYthat matches neither a pinnedlink_idnor afrom_anyslot forVALUE’s(name, tag)is a dead key and is rejected. - A pinned binding whose target
instance_iddeploys a different node than the slot expects is a target mismatch and is rejected. KEYuniqueness within a single invocation is enforced (no two bindings on the same consumer may share the sameKEY).- Stack-wide
instance_iduniqueness. Everyinstance_idmust be unique across the entire stack, not just within a(node_name, node_tag)group. Bindings address producers byinstance_id, so a duplicate would make the binding ambiguous.
Error handling
Section titled “Error handling”Service calls can fail with three error types:
- ServiceUnreachable: no instance is listening for that service.
- ServiceTimeout: no response was received within the timeout.
- ServiceError: the handler returned an error, which is propagated back to the caller.
If the service handler returns an Err, the error is forwarded to the caller rather than silently timing out.
This means a failing handler does not block the service from continuing to accept new requests.