Skip to content

Commit ae08f65

Browse files
feat: add promise-based service calls
1 parent 525fba1 commit ae08f65

File tree

5 files changed

+578
-7
lines changed

5 files changed

+578
-7
lines changed

example/services/README.md

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,57 @@ ROS 2 services provide a request-response communication pattern where clients se
4848
- Asynchronous request handling with callbacks
4949
- **Run Command**: `node example/services/client/client-example.js`
5050

51+
#### Async Service Client (`client/async-client-example.js`)
52+
53+
**Purpose**: Demonstrates modern async/await patterns for service communication, solving callback hell and providing cleaner error handling.
54+
55+
- **Service Type**: `example_interfaces/srv/AddTwoInts`
56+
- **Service Name**: `add_two_ints`
57+
- **Functionality**:
58+
- Multiple examples showing different async patterns
59+
- Simple async/await calls without callbacks
60+
- Timeout handling with configurable timeouts
61+
- Request cancellation using AbortController
62+
- Sequential and parallel service calls
63+
- Comprehensive error handling
64+
- **Features**:
65+
- **Modern JavaScript**: Clean async/await syntax instead of callback hell
66+
- **Timeout Support**: Built-in timeout handling with `options.timeout`
67+
- **Cancellation**: Request cancellation using `AbortController` and `options.signal`
68+
- **Error Types**: Specific error types (`TimeoutError`, `AbortError`) for better error handling (async only)
69+
- **Backward Compatible**: Works alongside existing callback-based `sendRequest()`
70+
- **TypeScript Ready**: Full type safety with comprehensive TypeScript definitions
71+
- **Run Command**: `node example/services/client/async-client-example.js`
72+
73+
**Key API Differences**:
74+
75+
```javascript
76+
client.sendRequest(request, (response) => {
77+
console.log('Response:', response.sum);
78+
});
79+
80+
try {
81+
const response = await client.sendRequestAsync(request);
82+
83+
const response = await client.sendRequestAsync(request, { timeout: 5000 });
84+
85+
const controller = new AbortController();
86+
const response = await client.sendRequestAsync(request, {
87+
signal: controller.signal,
88+
});
89+
90+
console.log('Response:', response.sum);
91+
} catch (error) {
92+
if (error.name === 'TimeoutError') {
93+
console.log('Request timed out');
94+
} else if (error.name === 'AbortError') {
95+
console.log('Request was cancelled');
96+
} else {
97+
console.error('Service error:', error.message);
98+
}
99+
}
100+
```
101+
51102
### GetMap Service
52103

53104
#### Service Server (`service/getmap-service-example.js`)
@@ -129,6 +180,41 @@ ROS 2 services provide a request-response communication pattern where clients se
129180
Result: object { sum: 79n }
130181
```
131182

183+
### Running the Async AddTwoInts Client Example
184+
185+
1. **Prerequisites**: Ensure ROS 2 is installed and sourced
186+
187+
2. **Start the Service Server**: Use the same service server as above:
188+
189+
```bash
190+
cd /path/to/rclnodejs
191+
node example/services/service/service-example.js
192+
```
193+
194+
3. **Start the Async Client**: In another terminal, run:
195+
196+
```bash
197+
cd /path/to/rclnodejs
198+
node example/services/client/async-client-example.js
199+
```
200+
201+
4. **Expected Output**:
202+
203+
**Service Server Terminal**: (Same as regular client)
204+
205+
```
206+
Incoming request: object { a: 42n, b: 37n }
207+
Sending response: object { sum: 79n }
208+
--
209+
```
210+
211+
**Async Client Terminal**:
212+
213+
```
214+
Sending: object { a: 42n, b: 37n }
215+
Result: object { sum: 79n }
216+
```
217+
132218
### Running the GetMap Service Example
133219

134220
1. **Prerequisites**: Ensure ROS 2 is installed and sourced
@@ -236,8 +322,11 @@ This script automatically starts the service, tests the client, and cleans up.
236322

237323
### Programming Patterns
238324

239-
- **Async/Await**: Modern JavaScript patterns for asynchronous operations
240-
- **Callback Handling**: Response processing using callback functions
325+
- **Modern Async/Await**: Clean Promise-based service calls with `sendRequestAsync()`
326+
- **Traditional Callbacks**: Response processing using callback functions with `sendRequest()`
327+
- **Error Handling**: Proper error handling with try/catch blocks and specific error types (async only)
328+
- **Timeout Management**: Built-in timeout support to prevent hanging requests (async only)
329+
- **Request Cancellation**: AbortController support for user-cancellable operations (async only)
241330
- **Resource Management**: Proper node shutdown and cleanup
242331
- **Data Analysis**: Processing and interpreting received data
243332
- **Visualization**: Converting data to human-readable formats
@@ -331,18 +420,23 @@ int8[] data
331420
### Common Issues
332421

333422
1. **Service Not Available**:
334-
335423
- Ensure the service server is running before starting the client
336424
- Check that both use the same service name (`add_two_ints`)
337425

338426
2. **Type Errors**:
339-
340427
- Ensure you're using `BigInt()` for integer values, not regular numbers
341428
- Use `response.template` to get the correct response structure
342429

343430
3. **Client Hangs**:
344431
- The client waits for service availability with a 1-second timeout
345432
- If the service isn't available, the client will log an error and shut down
433+
- For async clients, use timeout options: `client.sendRequestAsync(request, { timeout: 5000 })`
434+
435+
4. **Async/Await Issues** (applies only to `sendRequestAsync()`):
436+
- **Unhandled Promise Rejections**: Always use try/catch blocks around `sendRequestAsync()`
437+
- **Timeout Errors**: Handle `TimeoutError` specifically for timeout scenarios (async only)
438+
- **Cancelled Requests**: Handle `AbortError` when using AbortController cancellation (async only)
439+
- **Mixed Patterns**: You can use both `sendRequest()` and `sendRequestAsync()` in the same code
346440

347441
### Debugging Tips
348442

@@ -354,6 +448,10 @@ int8[] data
354448

355449
- Both examples use the standard rclnodejs initialization pattern
356450
- The service server runs continuously until manually terminated
357-
- The client performs a single request-response cycle then exits
451+
- The traditional client performs a single request-response cycle then exits
452+
- The async client demonstrates multiple patterns and then exits
453+
- **New async/await support**: Use `sendRequestAsync()` for modern Promise-based patterns
454+
- **Full backward compatibility**: Existing `sendRequest()` callback-based code continues to work unchanged
455+
- **TypeScript support**: Full type safety available for async methods
358456
- Service introspection is only available in ROS 2 Iron and later distributions
359457
- BigInt is required for integer message fields to maintain precision
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (c) 2025 Mahmoud Alghalayini. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
'use strict';
16+
17+
const rclnodejs = require('../../../index.js');
18+
19+
async function main() {
20+
await rclnodejs.init();
21+
const node = rclnodejs.createNode('async_client_example_node');
22+
const client = node.createClient(
23+
'example_interfaces/srv/AddTwoInts',
24+
'add_two_ints'
25+
);
26+
27+
if (
28+
rclnodejs.DistroUtils.getDistroId() >
29+
rclnodejs.DistroUtils.getDistroId('humble')
30+
) {
31+
// To view service events use the following command:
32+
// ros2 topic echo "/add_two_ints/_service_event"
33+
client.configureIntrospection(
34+
node.getClock(),
35+
rclnodejs.QoS.profileSystemDefault,
36+
rclnodejs.ServiceIntrospectionStates.METADATA
37+
);
38+
}
39+
40+
const request = {
41+
a: BigInt(Math.floor(Math.random() * 100)),
42+
b: BigInt(Math.floor(Math.random() * 100)),
43+
};
44+
45+
let result = await client.waitForService(1000);
46+
if (!result) {
47+
console.log('Error: service not available');
48+
rclnodejs.shutdown();
49+
return;
50+
}
51+
52+
rclnodejs.spin(node);
53+
54+
console.log(`Sending: ${typeof request}`, request);
55+
56+
try {
57+
const response = await client.sendRequestAsync(request, { timeout: 5000 });
58+
console.log(`Result: ${typeof response}`, response);
59+
} catch (error) {
60+
console.log(`Error: ${error.message}`);
61+
} finally {
62+
rclnodejs.shutdown();
63+
}
64+
}
65+
66+
main();

lib/client.js

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class Client extends Entity {
3333
}
3434

3535
/**
36-
* This callback is called when a resopnse is sent back from service
36+
* This callback is called when a response is sent back from service
3737
* @callback ResponseCallback
3838
* @param {Object} response - The response sent from the service
3939
* @see [Client.sendRequest]{@link Client#sendRequest}
@@ -43,7 +43,7 @@ class Client extends Entity {
4343
*/
4444

4545
/**
46-
* Send the request and will be notified asynchronously if receiving the repsonse.
46+
* Send the request and will be notified asynchronously if receiving the response.
4747
* @param {object} request - The request to be submitted.
4848
* @param {ResponseCallback} callback - Thc callback function for receiving the server response.
4949
* @return {undefined}
@@ -65,6 +65,88 @@ class Client extends Entity {
6565
this._sequenceNumberToCallbackMap.set(sequenceNumber, callback);
6666
}
6767

68+
/**
69+
* Send the request and return a Promise that resolves with the response.
70+
* @param {object} request - The request to be submitted.
71+
* @param {object} [options] - Optional parameters for the request.
72+
* @param {number} [options.timeout] - Timeout in milliseconds for the request.
73+
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
74+
* @return {Promise<object>} Promise that resolves with the service response.
75+
* @throws {Error} Throws error if request fails, times out, or is aborted.
76+
*/
77+
sendRequestAsync(request, options = {}) {
78+
return new Promise((resolve, reject) => {
79+
let sequenceNumber = null;
80+
let timeoutId = null;
81+
let isResolved = false;
82+
83+
const cleanup = () => {
84+
if (timeoutId) {
85+
clearTimeout(timeoutId);
86+
timeoutId = null;
87+
}
88+
if (sequenceNumber !== null) {
89+
this._sequenceNumberToCallbackMap.delete(sequenceNumber);
90+
}
91+
isResolved = true;
92+
};
93+
94+
if (options.signal) {
95+
if (options.signal.aborted) {
96+
const error = new Error('Request was aborted');
97+
error.name = 'AbortError';
98+
reject(error);
99+
return;
100+
}
101+
102+
options.signal.addEventListener('abort', () => {
103+
if (!isResolved) {
104+
cleanup();
105+
const error = new Error('Request was aborted');
106+
error.name = 'AbortError';
107+
reject(error);
108+
}
109+
});
110+
}
111+
112+
if (options.timeout !== undefined && options.timeout >= 0) {
113+
timeoutId = setTimeout(() => {
114+
if (!isResolved) {
115+
cleanup();
116+
const error = new Error(
117+
`Service call timeout after ${options.timeout}ms`
118+
);
119+
error.name = 'TimeoutError';
120+
error.code = 'TIMEOUT';
121+
reject(error);
122+
}
123+
}, options.timeout);
124+
}
125+
126+
try {
127+
let requestToSend =
128+
request instanceof this._typeClass.Request
129+
? request
130+
: new this._typeClass.Request(request);
131+
132+
let rawRequest = requestToSend.serialize();
133+
sequenceNumber = rclnodejs.sendRequest(this._handle, rawRequest);
134+
135+
debug(`Client has sent a ${this._serviceName} request (async).`);
136+
137+
this._sequenceNumberToCallbackMap.set(sequenceNumber, (response) => {
138+
if (!isResolved) {
139+
cleanup();
140+
resolve(response);
141+
}
142+
});
143+
} catch (error) {
144+
cleanup();
145+
reject(error);
146+
}
147+
});
148+
}
149+
68150
processResponse(sequenceNumber, response) {
69151
if (this._sequenceNumberToCallbackMap.has(sequenceNumber)) {
70152
debug(`Client has received ${this._serviceName} response from service.`);

0 commit comments

Comments
 (0)