Skip to content

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.

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.

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 a request_message_format is 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.

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"]
},
}

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);

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_any is absent or false): the slot must be bound. The generated poll resolves the bound producer’s instance_id from 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 generated poll resolves the binding and addresses the bound producer’s instance_id directly, 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:

Terminal window
peppy node run --bind uvc_camera@my-camera-instance .

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:

  1. If pinned slots’ bindings name the producer the call resolves to → the producer’s call lands on that pinned slot.
  2. Otherwise, if a from_any slot exists for the producer’s (name, tag) and is either explicitly bound to that producer or unbound (wildcard fallback) → the call lands on the from_any slot.
  3. Otherwise the slot has no matching binding and the call cannot be resolved.

A consumer that wires two specific depth cameras to dedicated wrist slots and lets a third slot reach any other depth camera:

openarm01_backbone/peppy.json5
{
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 },
],
},
},
// ...
}
peppy_launcher.json5
{
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:

  1. wrist_left_camera_<service>::poll(...) reaches left_cam (pinned-bound).
  2. wrist_right_camera_<service>::poll(...) reaches right_cam (pinned-bound).
  3. extra_cam_<service>::poll(...) reaches ceiling_cam via the unbound from_any wildcard (the discover-then-pin probe lands there because the pinned slots already claim the other producers).
  4. If extra_cam had been bound (e.g. extra_cam: "ceiling_cam"), discovery would be restricted to the bound set instead of every unclaimed producer.

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.

The launcher validator runs these checks before the stack starts:

  1. Pinned-unbound is a hard error. Every pinned slot in depends_on must have a binding whose KEY equals the slot’s link_id.
  2. A KEY that matches a pinned slot’s link_id binds that slot to VALUE.
  3. Free-form KEYs are allowed for from_any slots. A binding whose KEY doesn’t match a pinned link_id is accepted as long as some from_any slot exists for VALUE’s (name, tag); multiple such bindings on the same from_any slot accumulate.
  4. A KEY that matches neither a pinned link_id nor a from_any slot for VALUE’s (name, tag) is a dead key and is rejected.
  5. A pinned binding whose target instance_id deploys a different node than the slot expects is a target mismatch and is rejected.
  6. KEY uniqueness within a single invocation is enforced (no two bindings on the same consumer may share the same KEY).
  7. Stack-wide instance_id uniqueness. Every instance_id must be unique across the entire stack, not just within a (node_name, node_tag) group. Bindings address producers by instance_id, so a duplicate would make the binding ambiguous.

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.