ros2-service-action
ROS2 services and actions guidance for request-response and long-running cancellable operations. Use when choosing between service and action interfaces, defining srv or action files, implementing rcl
SKILL.md.agents/skills/ros2-service-action/SKILL.md
GitHubROS2 Services and Actions
When to Use This Skill
- Deciding between a service and an action for a new RPC
- Defining a
.srvor.actioninterface that won't need to change in 6 months - Implementing a service server / client in Python or C++
- Implementing an action server / client with goal, feedback, result, and cancellation
- Designing structured error codes that clients can act on
- Debugging "service call hangs forever" or "action goal accepted but never executes"
Service vs. Action: The Decision
| Use a service when | Use an action when |
|---|---|
| The operation completes in well under a second | The operation takes seconds, minutes, or longer |
| There's nothing useful to report mid-flight | The client wants progress feedback |
| The client never wants to cancel | The client may want to cancel (timeout, replan, e-stop) |
| Setting/getting state, querying a calibration | Pick-and-place, navigation, calibration routines, scanning |
If you're tempted to add a "is_done" service polled in a loop, you wanted an action.
Defining Interfaces
Put both .srv and .action files in your _msgs/_interfaces package (see ros2-messaging).
Service .srv
# srv/SetMode.srv
uint8 MODE_MANUAL = 0
uint8 MODE_AUTO = 1
uint8 MODE_EMERGENCY = 2
uint8 mode
bool force
---
bool success
string message
uint8 previous_mode
The --- separates request from response. Constants (the MODE_* lines) provide enum-like values.
Action .action
# action/NavigateToGoal.action
geometry_msgs/PoseStamped target_pose
float32 max_velocity
bool allow_replanning
---
# Result
uint8 ERROR_NONE = 0
uint8 ERROR_OBSTRUCTED = 1
uint8 ERROR_TIMEOUT = 2
uint8 ERROR_INVALID_GOAL = 3
uint8 error_code
string error_message
float32 total_distance
float32 total_time
---
# Feedback
geometry_msgs/PoseStamped current_pose
float32 distance_remaining
float32 estimated_time_remaining
uint8 recovery_count
The two --- separators delimit goal / result / feedback.
Use enum-style error codes, not raw strings
When an action can fail for multiple reasons, use a numeric error_code field with constants. This lets clients branch on failure type, supports localization of error messages, and survives string typos.
# Bad — clients have to grep error_message.contains("obstructed")
string error_message
---
# Good — clients can do `if result.error_code == ERROR_OBSTRUCTED: replan()`
uint8 ERROR_NONE = 0
uint8 ERROR_OBSTRUCTED = 1
uint8 error_code
string error_message
Service Server
Python
import rclpy
from rclpy.node import Node
from robot_interfaces.srv import SetMode
class ModeService(Node):
def __init__(self):
super().__init__('mode_service')
self._mode = SetMode.Request.MODE_MANUAL
self._srv = self.create_service(
SetMode, 'robot/set_mode', self._handle)
def _handle(self, request, response):
try:
previous = self._mode
self._mode = request.mode # delegate to use case in real code
response.success = True
response.message = 'mode set'
response.previous_mode = previous
except ValueError as e:
response.success = False
response.message = str(e)
return response
C++
#include <rclcpp/rclcpp.hpp>
#include <robot_interfaces/srv/set_mode.hpp>
class ModeService : public rclcpp::Node {
public:
ModeService() : Node("mode_service") {
using std::placeholders::_1;
using std::placeholders::_2;
srv_ = create_service<robot_interfaces::srv::SetMode>(
"robot/set_mode",
std::bind(&ModeService::handle, this, _1, _2));
}
private:
void handle(const robot_interfaces::srv::SetMode::Request::SharedPtr req,
robot_interfaces::srv::SetMode::Response::SharedPtr res) {
try {
const auto previous = mode_;
mode_ = req->mode;
res->success = true;
res->message = "mode set";
res->previous_mode = previous;
} catch (const std::exception& e) {
res->success = false;
res->message = e.what();
}
}
uint8_t mode_ = robot_interfaces::srv::SetMode::Request::MODE_MANUAL;
rclcpp::Service<robot_interfaces::srv::SetMode>::SharedPtr srv_;
};
Service Client
Python — call from a separate thread to avoid deadlock
class ModeClient(Node):
def __init__(self):
super().__init__('mode_client')
self._client = self.create_client(SetMode, 'robot/set_mode')
while not self._client.wait_for_service(timeout_sec=1.0):
self.get_logger().info('waiting for /robot/set_mode...')
async def set_mode(self, mode: int) -> bool:
req = SetMode.Request(); req.mode = mode
future = self._client.call_async(req)
result = await future
return result.success
C++ async client
auto client = node->create_client<robot_interfaces::srv::SetMode>("robot/set_mode");
auto request = std::make_shared<robot_interfaces::srv::SetMode::Request>();
request->mode = robot_interfaces::srv::SetMode::Request::MODE_AUTO;
auto future = client->async_send_request(request);
if (rclcpp::spin_until_future_complete(node, future) ==
rclcpp::FutureReturnCode::SUCCESS) {
auto response = future.get();
}
Calling a service from inside a subscription callback? On
SingleThreadedExecutorthis self-deadlocks: the callback can't return until the service replies, but the executor can't process the reply until the callback returns. Fix:MultiThreadedExecutorand put the client in aReentrantCallbackGroup. Seeros2-node-creation.
Action Server
Python — the four callbacks
import rclpy
from rclpy.node import Node
from rclpy.action import ActionServer, GoalResponse, CancelResponse
from rclpy.callback_groups import ReentrantCallbackGroup
from robot_interfaces.action import NavigateToGoal
class NavServer(Node):
def __init__(self):
super().__init__('nav_server')
self._action_server = ActionServer(
self, NavigateToGoal, 'navigate_to_goal',
execute_callback=self._execute,
goal_callback=self._goal,
cancel_callback=self._cancel,
callback_group=ReentrantCallbackGroup(),
)
def _goal(self, goal_request):
if goal_request.max_velocity <= 0.0:
return GoalResponse.REJECT
return GoalResponse.ACCEPT
def _cancel(self, goal_handle):
return CancelResponse.ACCEPT
async def _execute(self, goal_handle):
feedback = NavigateToGoal.Feedback()
result = NavigateToGoal.Result()
for step in self._plan(goal_handle.request):
if goal_handle.is_cancel_requested:
goal_handle.canceled()
result.error_code = NavigateToGoal.Result.ERROR_NONE
result.error_message = 'canceled'
return result
self._drive(step)
feedback.distance_remaining = step.distance_remaining
feedback.current_pose = step.current_pose
goal_handle.publish_feedback(feedback)
goal_handle.succeed()
result.error_code = NavigateToGoal.Result.ERROR_NONE
return result
C++
#include <rclcpp/rclcpp.hpp>
#include <rclcpp_action/rclcpp_action.hpp>
#include <robot_interfaces/action/navigate_to_goal.hpp>
using NavigateToGoal = robot_interfaces::action::NavigateToGoal;
using GoalHandle = rclcpp_action::ServerGoalHandle<NavigateToGoal>;
class NavServer : public rclcpp::Node {
public:
NavServer() : Node("nav_server") {
action_server_ = rclcpp_action::create_server<NavigateToGoal>(
this, "navigate_to_goal",
std::bind(&NavServer::handle_goal, this, std::placeholders::_1, std::placeholders::_2),
std::bind(&NavServer::handle_cancel, this, std::placeholders::_1),
std::bind(&NavServer::handle_accepted,this, std::placeholders::_1));
}
private:
rclcpp_action::GoalResponse handle_goal(
const rclcpp_action::GoalUUID&,
std::shared_ptr<const NavigateToGoal::Goal> goal) {
if (goal->max_velocity <= 0.0) return rclcpp_action::GoalResponse::REJECT;
return rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE;
}
rclcpp_action::CancelResponse handle_cancel(std::shared_ptr<GoalHandle>) {
return rclcpp_action::CancelResponse::ACCEPT;
}
void handle_accepted(std::shared_ptr<GoalHandle> handle) {
std::thread{std::bind(&NavServer::execute, this, handle)}.detach();
}
void execute(std::shared_ptr<GoalHandle> handle) {
auto feedback = std::make_shared<NavigateToGoal::Feedback>();
auto result = std::make_shared<NavigateToGoal::Result>();
// ... step loop with handle->is_canceling() checks ...
result->error_code = NavigateToGoal::Result::ERROR_NONE;
handle->succeed(result);
}
rclcpp_action::Server<NavigateToGoal>::SharedPtr action_server_;
};
Action Client
from rclpy.action import ActionClient
class NavClient(Node):
def __init__(self):
super().__init__('nav_client')
self._client = ActionClient(self, NavigateToGoal, 'navigate_to_goal')
async def navigate(self, target_pose, on_feedback):
self._client.wait_for_server()
goal = NavigateToGoal.Goal()
goal.target_pose = target_pose
goal.max_velocity = 0.5
goal.allow_replanning = True
send_goal_future = self._client.send_goal_async(
goal, feedback_callback=lambda fb: on_feedback(fb.feedback))
goal_handle = await send_goal_future
if not goal_handle.accepted:
return None
result_future = goal_handle.get_result_async()
wrapped = await result_future
return wrapped.result
auto client = rclcpp_action::create_client<NavigateToGoal>(node, "navigate_to_goal");
client->wait_for_action_server();
auto goal_msg = NavigateToGoal::Goal();
goal_msg.target_pose = target;
goal_msg.max_velocity = 0.5;
auto options = rclcpp_action::Client<NavigateToGoal>::SendGoalOptions();
options.feedback_callback =
[] (rclcpp_action::ClientGoalHandle<NavigateToGoal>::SharedPtr,
const std::shared_ptr<const NavigateToGoal::Feedback> fb) {
RCLCPP_INFO(rclcpp::get_logger("nav"), "remaining: %.2f", fb->distance_remaining);
};
options.result_callback =
[] (const rclcpp_action::ClientGoalHandle<NavigateToGoal>::WrappedResult& wrapped) {
// handle wrapped.code (SUCCEEDED, CANCELED, ABORTED) and wrapped.result->error_code
};
client->async_send_goal(goal_msg, options);
Common Pitfalls
| Pitfall | What goes wrong | Fix |
|---|---|---|
| Using a service for an operation that takes >1s | Client times out, retries cause duplicate work | Use an action; expose progress + cancel |
| Polling an action's status with a service | Race conditions, missed completion | Subscribe to the action's feedback or use get_result_async |
| Service call from inside a subscription on single-threaded executor | Self-deadlock: callback waits for reply, executor can't deliver it | MultiThreadedExecutor + ReentrantCallbackGroup for the client |
Returning failure as success=False only |
Caller can't distinguish failure types | Add structured error_code constants |
Action server doing heavy work in goal_callback |
New goals reject because previous goal still being validated | Validate quickly in goal_callback; do work in execute_callback |
Forgetting to call goal_handle.succeed() / canceled() / abort() |
Action stays in EXECUTING forever; clients hang | Every code path through execute_callback must terminate the handle |
| Spinning the same node from a client and a server | Deadlock when server replies through the same single-threaded executor | Put server and client in separate executors or use ReentrantCallbackGroup |
Related Skills
- ros2-messaging — defining the
_msgspackage that holds your.srvand.actionfiles - ros2-node-creation — executor and callback-group choices for clients and servers
- ros2-lifecycle — many lifecycle managers expose state changes as services
- ros2-testing — integration-testing servers without spinning a full system