Lesson 5.2: Service Clients and Async Patterns
In the previous lesson, you built a service server—a node that waits for requests. Now you'll build the other side: a service client that sends requests and waits for responses.
A service client is like making a phone call. You dial (make a request), wait on hold (block until response), then the other person responds. You need to handle the case where no one is home (service not available), and you need to know what to do with the answer.
In this lesson, you'll learn both synchronous (simpler, blocking) and asynchronous (more complex, non-blocking) patterns. Most real robots use asynchronous because they can't block waiting for a response while other critical tasks run.
Synchronous vs Asynchronous Service Calls
Synchronous (Simple)
# Call service, block until response
response = node.call_service(request)
print(response.result)
# Continue here - response already arrived
Pros:
- Simple code: "call, then use response"
- Easy to understand flow
Cons:
- Blocks the entire node while waiting
- If service hangs, the whole node freezes
Asynchronous (Realistic)
# Send request, don't block
future = node.call_service_async(request)
# Continue doing other work here
# ...
# Later, check if response arrived
if future.done():
response = future.result()
print(response.result)
Pros:
- Node keeps running while waiting
- Can handle multiple requests
- More like real robots
Cons:
- More complex code
- Need to check when response arrives
Real robotics reason: A robot can't freeze while waiting for one service. It needs to keep publishing sensor data, listening to commands, updating the display—all at the same time.
The Synchronous Pattern (Simple, Blocking)
Here's the simplest way to call a service:
from example_interfaces.srv import SetBool
import rclpy
from rclpy.node import Node
class SimpleClient(Node):
def __init__(self):
super().__init__('simple_client')
# Create a client (not a service!)
self.cli = self.create_client(SetBool, 'enable_robot')
def call_enable_robot(self, enable: bool):
"""Send enable/disable request to robot server."""
# Wait for service to be available
while not self.cli.wait_for_service(timeout_sec=1.0):
self.get_logger().info('Service not available, waiting...')
# Create the request
request = SetBool.Request()
request.data = enable
# Call service and BLOCK until response arrives
response = self.cli.call(request)
# Now we have the response
self.get_logger().info(f'Response: {response.message}')
return response
def main(args=None):
rclpy.init(args=args)
client = SimpleClient()
# Enable the robot
client.call_enable_robot(True)
# Later...
client.call_enable_robot(False)
client.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
Let's understand each part:
Creating the Client:
self.cli = self.create_client(SetBool, 'enable_robot')
This creates a client (different from a server!):
- Argument 1 (
SetBool): Service type - Argument 2 (
'enable_robot'): Service name to call
Waiting for Service:
while not self.cli.wait_for_service(timeout_sec=1.0):
self.get_logger().info('Service not available, waiting...')
Critical: Always wait before calling. The service might not be running yet:
- If service is available, returns immediately
- If not, waits up to 1 second
- If still not available, loops and waits again
- If you skip this and service isn't running, the call fails
Creating the Request:
request = SetBool.Request()
request.data = enable
Create an empty request object and fill in the fields.
Calling Synchronously:
response = self.cli.call(request)
This blocks the entire node:
- Sends request to server
- Waits for response
- Code below this line doesn't run until response arrives
- Then you have the response
The Asynchronous Pattern (Realistic, Non-blocking)
In real robotics, you can't block. Here's the async pattern:
from example_interfaces.srv import SetBool
import rclpy
from rclpy.node import Node
class AsyncClient(Node):
def __init__(self):
super().__init__('async_client')
self.cli = self.create_client(SetBool, 'enable_robot')
self.future = None
def call_enable_async(self, enable: bool):
"""Send request WITHOUT blocking."""
# Wait for service first
if not self.cli.wait_for_service(timeout_sec=1.0):
self.get_logger().error('Service not available')
return
# Create request
request = SetBool.Request()
request.data = enable
# Send request but DON'T BLOCK
self.future = self.cli.call_async(request)
self.get_logger().info(f'Request sent, not blocked')
def check_response(self):
"""Check if response has arrived yet."""
if self.future is None:
return None
# Is response ready?
if self.future.done():
response = self.future.result()
self.get_logger().info(f'Response arrived: {response.message}')
return response
else:
self.get_logger().info('Still waiting for response...')
return None
def main(args=None):
rclpy.init(args=args)
client = AsyncClient()
# Send request (doesn't block)
client.call_enable_async(True)
# Node can do other work here
# In a real robot, this might be publishing sensor data
# Later, check if response arrived
client.check_response()
client.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
Key differences from sync:
# Async: returns immediately
self.future = self.cli.call_async(request)
# Code continues here - request still being processed!
# Later:
if self.future.done():
response = self.future.result()
The future object represents "I sent the request, response coming soon." You check it later with .done().
The Production Pattern (Async with Spin)
Real robots use ROS 2's rclpy.spin() with a callback. Here's the pattern most used:
from example_interfaces.srv import SetBool
import rclpy
from rclpy.node import Node
class ProductionClient(Node):
def __init__(self):
super().__init__('production_client')
self.cli = self.create_client(SetBool, 'enable_robot')
# Wait once at startup
while not self.cli.wait_for_service(timeout_sec=1.0):
self.get_logger().info('Waiting for enable_robot service...')
# Create a timer to send requests periodically
self.timer = self.create_timer(2.0, self.send_request)
self.request_count = 0
def send_request(self):
"""Called every 2 seconds."""
self.request_count += 1
enable = self.request_count % 2 == 0 # Alternate True/False
request = SetBool.Request()
request.data = enable
# Send async and attach callback for when response arrives
future = self.cli.call_async(request)
future.add_done_callback(self.response_callback)
self.get_logger().info(f'Request #{self.request_count} sent')
def response_callback(self, future):
"""Called when response arrives."""
try:
response = future.result()
self.get_logger().info(f'Response: {response.message}')
except Exception as e:
self.get_logger().error(f'Service call failed: {e}')
def main(args=None):
rclpy.init(args=args)
client = ProductionClient()
# Keep running, spin handles timers and callbacks
rclpy.spin(client)
client.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
This is the production pattern:
- Create client and wait for service at startup
- Use a timer to send requests periodically
- Attach a callback with
future.add_done_callback() - When response arrives, callback executes automatically
rclpy.spin()keeps everything running
Testing Your Client
Let's test with the server from the previous lesson running.
Terminal 1: Start the server
ros2 run my_first_package enable_service
Terminal 2: Run the production client
Create a file service_client.py:
from example_interfaces.srv import SetBool
import rclpy
from rclpy.node import Node
class ClientNode(Node):
def __init__(self):
super().__init__('client_node')
self.cli = self.create_client(SetBool, 'enable_robot')
while not self.cli.wait_for_service(timeout_sec=1.0):
self.get_logger().info('Service not found, waiting...')
# Send request immediately
request = SetBool.Request()
request.data = True
future = self.cli.call_async(request)
future.add_done_callback(self.handle_response)
def handle_response(self, future):
response = future.result()
self.get_logger().info(f'Result: {response.message}')
def main(args=None):
rclpy.init(args=args)
node = ClientNode()
rclpy.spin(node)
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
Add to setup.py:
'service_client = my_first_package.service_client:main',
Build and run:
cd ~/ros2_ws && colcon build
source install/setup.bash
ros2 run my_first_package service_client
Terminal 1 (server) will show:
[INFO] [robot_enabler]: Robot has been ENABLED
Terminal 2 (client) will show:
[INFO] [client_node]: Result: Robot enabled. Battery: 85%
Error Handling Patterns
What happens if:
- Service is not running?
- Service crashes mid-response?
- Network is slow?
def send_request_safely(self, request):
"""Safe request with proper error handling."""
try:
# Check service exists
if not self.cli.wait_for_service(timeout_sec=1.0):
self.get_logger().error('Service not available')
return None
# Send request
future = self.cli.call_async(request)
# Wait for response (max 5 seconds)
rclpy.spin_until_future_complete(
self, future, timeout_sec=5.0)
# Check if we got a response
if future.result() is None:
self.get_logger().error('Service call failed')
return None
return future.result()
except Exception as e:
self.get_logger().error(f'Error: {e}')
return None
The key functions:
wait_for_service(): Check service existsspin_until_future_complete(): Wait up to N seconds for responsefuture.result(): Get the actual response
Key Insights
Wait_for_service is Essential: Never assume a service is running. Always wait.
Sync vs Async Decision:
- Sync: Simple one-off calls (startup, initialization)
- Async: Real nodes that keep running
Callbacks are the Pattern:
In real ROS 2, you don't "wait". You attach callbacks and let rclpy.spin() manage the timing.
Timeouts Matter: Always specify timeouts. A frozen service shouldn't freeze your node.
Try With AI
You have a working async client. Let's improve it.
Ask your AI:
"I have a service client that sends requests every 2 seconds and handles responses with a callback. I want to add: (1) retry logic if service isn't available, (2) a counter that tracks successful vs failed requests, (3) logging of request/response times."
Expected outcome: AI will suggest:
- Loop with exponential backoff for service availability
- Add success/failure counters
- Use time.time() to measure round-trip time
- Log metrics every 10 requests
Challenge the implementation:
"Good, but what if the service becomes unavailable while the client is running? Should I keep retrying forever or give up?"
Expected outcome: AI will explain:
- Option 1: Retry N times then fail
- Option 2: Keep retrying but log warnings
- Option 3: Use ros2 param to make it configurable
- Discuss tradeoffs in robustness vs resource usage
Iterate together:
"I like option 3. Show me how to add a 'max_retries' parameter that the user can set. If retries exceeded, log an error but don't crash."
This demonstrates AI teaching patterns, you refining requirements, and together reaching the best solution.
Exercises
- Create a simple client that enables the robot, waits 2 seconds, then disables it
- Add retry logic that attempts the service call up to 3 times before failing
- Time the response and log how long the request took
- Create two clients in the same package calling different services (you'll need a second server)
- Add a parameter for request frequency so it can be changed at runtime
Reflection
Before moving to the next lesson, consider:
- Why can't a real robot use synchronous service calls?
- What's the difference between
call()andcall_async()? - When would you use
wait_for_service()vs just hoping the service exists?
Next lesson: Custom message and service types. So far you've used built-in types like AddTwoInts and SetBool. What if you need to send custom data structures? That's when you create .srv files.