diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index a840a477..0408027c 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -42,6 +42,7 @@ - JSON safe serialization improvements - Promise-based service calls implementation - Add ParameterClient for external parameter access + - Add structured error handling with class error hierarchy - **[Martins Mozeiko](https://github.com/martins-mozeiko)** - QoS new/delete fix diff --git a/example/error-handling/README.md b/example/error-handling/README.md new file mode 100644 index 00000000..8990e6ad --- /dev/null +++ b/example/error-handling/README.md @@ -0,0 +1,219 @@ +# Error Handling Examples + +This directory contains examples demonstrating structured error handling in rclnodejs. These examples showcase the comprehensive error class hierarchy for better debugging and error recovery patterns. + +## Overview + +rclnodejs provides 21 specialized error classes organized in a clear hierarchy, making it easier to catch, handle, and debug errors with rich context. All errors extend from `RclNodeError` and include properties like `code`, `nodeName`, `entityType`, `entityName`, and detailed context information. + +## Error Hierarchy + +``` +RclNodeError (base) +├── ValidationError +│ ├── TypeValidationError +│ ├── RangeValidationError +│ └── NameValidationError +├── OperationError +│ ├── TimeoutError +│ ├── AbortError +│ ├── ServiceNotFoundError +│ └── NodeNotFoundError +├── ParameterError +│ ├── ParameterNotFoundError +│ ├── ParameterTypeError +│ └── ReadOnlyParameterError +├── TopicError +│ ├── PublisherError +│ └── SubscriptionError +├── ActionError +│ ├── GoalRejectedError +│ └── ActionServerNotFoundError +└── NativeError +``` + +## Examples + +### 1. Type Validation (`error-handling-example.js` - Example 1) + +**Purpose**: Demonstrates catching `TypeValidationError` when providing wrong argument types. + +- **Functionality**: Attempts to create node with invalid type, catches and displays error details +- **Features**: Shows `argumentName`, `expectedType`, and `providedValue` properties +- **Key Properties**: `error.argumentName`, `error.expectedType`, `error.providedValue` + +### 2. Range Validation (`error-handling-example.js` - Example 2) + +**Purpose**: Demonstrates catching `RangeValidationError` for out-of-bounds values. + +- **Functionality**: Attempts to create rate with invalid frequency (>1000 Hz), catches error +- **Features**: Shows `validationRule` and `providedValue` for constraint violations +- **Key Properties**: `error.validationRule`, `error.providedValue`, `error.nodeName` + +### 3. Service Errors (`error-handling-example.js` - Example 3) + +**Purpose**: Demonstrates `TimeoutError` and `AbortError` handling for service operations. + +- **Functionality**: Shows timeout handling and manual request cancellation +- **Features**: Demonstrates timeout with `sendRequestAsync({ timeout })` and `AbortController` +- **Key Properties**: `error.timeout`, `error.operationType`, `error.entityName` + +### 4. Publisher Errors (`error-handling-example.js` - Example 4) + +**Purpose**: Demonstrates catching `PublisherError` during publisher creation. + +- **Functionality**: Attempts to create publisher with invalid message type +- **Features**: Shows error handling for topic creation failures +- **Key Class**: `PublisherError` + +### 5. Subscription Errors (`error-handling-example.js` - Example 5) + +**Purpose**: Demonstrates catching `SubscriptionError` during subscription creation. + +- **Functionality**: Attempts to create subscription with invalid message type +- **Features**: Shows error handling for subscription creation failures +- **Key Class**: `SubscriptionError` + +### 6. Parameter Errors (`error-handling-example.js` - Example 6) + +**Purpose**: Demonstrates `ParameterTypeError` for parameter type mismatches. + +- **Functionality**: Creates parameter with wrong value type, catches type error +- **Features**: Shows parameter validation and type checking +- **Key Class**: `ParameterTypeError` + +### 7. Name Validation (`error-handling-example.js` - Example 7) + +**Purpose**: Demonstrates `NameValidationError` for invalid ROS names. + +- **Functionality**: Validates topic name with invalid characters +- **Features**: Shows `invalidIndex` property pointing to error location +- **Key Properties**: `error.invalidIndex`, `error.message` + +### 8. Error Recovery (`error-handling-example.js` - Example 8) + +**Purpose**: Demonstrates retry logic with structured error handling. + +- **Functionality**: Implements retry pattern with different handling for timeout vs abort +- **Features**: Shows differentiation between recoverable and non-recoverable errors +- **Pattern**: Retry on `TimeoutError`, abort on `AbortError` or other errors + +### 9. Error Serialization (`error-handling-example.js` - Example 9) + +**Purpose**: Demonstrates using `toJSON()` for logging and debugging. + +- **Functionality**: Serializes error to JSON for structured logging +- **Features**: Shows `toJSON()` method producing complete error information +- **Method**: `error.toJSON()` + +### 10. Generic Error Handler (`error-handling-example.js` - Example 10) + +**Purpose**: Demonstrates reusable error handler for all rclnodejs errors. + +- **Functionality**: Implements `handleRclError()` function for consistent error handling +- **Features**: Extracts common error properties, handles specific error types +- **Pattern**: Single function handling all `RclNodeError` subclasses + +## How to Run + +1. **Prerequisites**: Ensure ROS 2 is installed and sourced + +2. **Run All Examples**: + + ```bash + node example/error-handling/error-handling-example.js + ``` + +3. **Expected Output**: Demonstrates all 10 error handling patterns with clear success indicators + +## Key Concepts Demonstrated + +### Error Properties + +All `RclNodeError` instances include: + +- `name`: Error class name (e.g., `'TypeValidationError'`) +- `message`: Human-readable error description +- `code`: Machine-readable error code (e.g., `'INVALID_TYPE'`) +- `nodeName`: Associated node name (when applicable) +- `entityType`: Entity type causing error (e.g., `'service'`, `'publisher'`) +- `entityName`: Specific entity name (e.g., topic/service name) +- `timestamp`: Error creation timestamp + +### Type-Specific Properties + +- **TypeValidationError**: `argumentName`, `expectedType`, `providedValue` +- **RangeValidationError**: `validationRule`, `providedValue` +- **NameValidationError**: `invalidIndex` +- **TimeoutError**: `timeout`, `operationType` +- **ParameterTypeError**: `argumentName`, `expectedType` + +### Error Methods + +- `toJSON()`: Serialize error to plain object for logging +- `toString()`: Format error as readable string with context + +### Error Handling Patterns + +- **Type Guards**: Use `instanceof` to check specific error types +- **Recovery Logic**: Different handling for recoverable vs non-recoverable errors +- **Error Chaining**: Support for `cause` property linking related errors +- **Structured Logging**: Use `toJSON()` for logging systems +- **Generic Handlers**: Single function handling multiple error types + +## Usage Examples + +### Basic Error Catching + +```javascript +try { + rclnodejs.createNode(123, 'namespace'); +} catch (error) { + if (error instanceof rclnodejs.TypeValidationError) { + console.log( + `Expected ${error.expectedType}, got ${typeof error.providedValue}` + ); + } +} +``` + +### Service Timeout Handling + +```javascript +try { + await client.sendRequestAsync(request, { timeout: 5000 }); +} catch (error) { + if (error instanceof rclnodejs.TimeoutError) { + console.log(`Timeout after ${error.timeout}ms`); + } else if (error instanceof rclnodejs.AbortError) { + console.log('Request cancelled'); + } +} +``` + +### Generic Error Handler + +```javascript +function handleRclError(error, operation) { + if (!(error instanceof rclnodejs.RclNodeError)) { + console.error(`Unexpected error: ${error.message}`); + return; + } + + const context = []; + if (error.nodeName) context.push(`node: ${error.nodeName}`); + if (error.entityName) + context.push(`${error.entityType}: ${error.entityName}`); + + console.error(`${error.name} in ${operation}: ${error.message}`); +} +``` + +## Notes + +- All error classes extend `RclNodeError` for consistent `instanceof` checks +- Error context properties are populated based on the operation +- TypeScript definitions provide full type safety for error handling +- Use `toJSON()` for integration with logging frameworks +- Error codes are machine-readable constants for programmatic handling +- Backward compatibility maintained - existing error handling continues to work diff --git a/example/error-handling/error-handling-example.js b/example/error-handling/error-handling-example.js new file mode 100644 index 00000000..a20c1e7d --- /dev/null +++ b/example/error-handling/error-handling-example.js @@ -0,0 +1,313 @@ +// Copyright (c) 2025 Mahmoud Alghalayini. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +const rclnodejs = require('../../index.js'); + +/** + * Example 1: Type Validation Errors + * Demonstrates catching TypeValidationError when providing wrong types + */ +async function example1_typeValidation() { + console.log('\n=== Example 1: Type Validation ==='); + await rclnodejs.init(); + + try { + rclnodejs.createNode(123, 'namespace'); + } catch (error) { + if (error instanceof rclnodejs.TypeValidationError) { + console.log( + `Expected ${error.expectedType}, got ${typeof error.providedValue}` + ); + } + } + + rclnodejs.shutdown(); +} + +/** + * Example 2: Range Validation Errors + * Demonstrates catching RangeValidationError for out-of-bounds values + */ +async function example2_rangeValidation() { + console.log('\n=== Example 2: Range Validation ==='); + await rclnodejs.init(); + const node = rclnodejs.createNode('my_node'); + + try { + await node.createRate(2000); + } catch (error) { + if (error instanceof rclnodejs.RangeValidationError) { + console.log(`Value ${error.providedValue} is ${error.validationRule}`); + } + } + + node.destroy(); + rclnodejs.shutdown(); +} + +/** + * Example 3: Service Operation Errors + * Demonstrates TimeoutError and AbortError handling + */ +async function example3_serviceErrors() { + console.log('\n=== Example 3: Service Errors ==='); + await rclnodejs.init(); + const node = rclnodejs.createNode('client_node'); + const client = node.createClient( + 'example_interfaces/srv/AddTwoInts', + 'add_service' + ); + + try { + await client.sendRequestAsync({ a: 1, b: 2 }, { timeout: 1 }); + } catch (error) { + if (error instanceof rclnodejs.TimeoutError) { + console.log(`${error.operationType} timed out after ${error.timeout}ms`); + } + } + + try { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 10); + await client.sendRequestAsync( + { a: 1, b: 2 }, + { signal: controller.signal } + ); + } catch (error) { + if (error instanceof rclnodejs.AbortError) { + console.log(`${error.operationType} was aborted`); + } + } + + node.destroy(); + rclnodejs.shutdown(); +} + +/** + * Example 4: Publisher Errors + * Demonstrates catching PublisherError + */ +async function example4_publisherErrors() { + console.log('\n=== Example 4: Publisher Errors ==='); + await rclnodejs.init(); + const node = rclnodejs.createNode('pub_node'); + + try { + node.createPublisher('InvalidMessageType', 'my_topic'); + } catch (error) { + if (error instanceof rclnodejs.PublisherError) { + console.log(`Publisher creation failed: ${error.message}`); + } + } + + node.destroy(); + rclnodejs.shutdown(); +} + +/** + * Example 5: Subscription Errors + * Demonstrates catching SubscriptionError + */ +async function example5_subscriptionErrors() { + console.log('\n=== Example 5: Subscription Errors ==='); + await rclnodejs.init(); + const node = rclnodejs.createNode('sub_node'); + + try { + node.createSubscription('InvalidMessageType', 'my_topic', () => {}); + } catch (error) { + if (error instanceof rclnodejs.SubscriptionError) { + console.log(`Subscription creation failed: ${error.message}`); + } + } + + node.destroy(); + rclnodejs.shutdown(); +} + +/** + * Example 6: Parameter Errors + * Demonstrates ParameterTypeError and parameter-related errors + */ +async function example6_parameterErrors() { + console.log('\n=== Example 6: Parameter Errors ==='); + await rclnodejs.init(); + const node = rclnodejs.createNode('param_node'); + + try { + new rclnodejs.Parameter( + 'test_param', + rclnodejs.ParameterType.PARAMETER_STRING, + 123 + ); + } catch (error) { + if (error instanceof rclnodejs.ParameterTypeError) { + console.log(`Parameter type mismatch: ${error.message}`); + } + } + + node.destroy(); + rclnodejs.shutdown(); +} + +/** + * Example 7: Name Validation Errors + * Demonstrates catching NameValidationError for invalid ROS names + */ +async function example7_nameValidation() { + console.log('\n=== Example 7: Name Validation ==='); + + try { + rclnodejs.validator.validateTopicName('invalid topic!'); + } catch (error) { + if (error instanceof rclnodejs.NameValidationError) { + console.log( + `Invalid name at index ${error.invalidIndex}: ${error.message}` + ); + } + } +} + +/** + * Example 8: Error Recovery Pattern + * Demonstrates retry logic with structured errors + */ +async function example8_errorRecovery() { + console.log('\n=== Example 8: Error Recovery ==='); + await rclnodejs.init(); + const node = rclnodejs.createNode('recovery_node'); + const client = node.createClient( + 'example_interfaces/srv/AddTwoInts', + 'add_service' + ); + + const maxRetries = 3; + let attempt = 0; + + while (attempt < maxRetries) { + try { + await client.sendRequestAsync({ a: 1, b: 2 }, { timeout: 100 }); + console.log('Success!'); + break; + } catch (error) { + attempt++; + + if (error instanceof rclnodejs.TimeoutError) { + if (attempt < maxRetries) { + console.log(`Retry ${attempt}/${maxRetries}`); + continue; + } + console.log('All retries exhausted'); + } else if (error instanceof rclnodejs.AbortError) { + console.log('User cancelled, no retry'); + break; + } else { + console.log('Unexpected error, no retry'); + break; + } + } + } + + node.destroy(); + rclnodejs.shutdown(); +} + +/** + * Example 9: Error Serialization + * Demonstrates using toJSON() for logging/debugging + */ +async function example9_errorSerialization() { + console.log('\n=== Example 9: Error Serialization ==='); + await rclnodejs.init(); + + try { + rclnodejs.createNode(null); + } catch (error) { + if (error instanceof rclnodejs.RclNodeError) { + const errorData = error.toJSON(); + console.log('Error details:', JSON.stringify(errorData, null, 2)); + } + } + + rclnodejs.shutdown(); +} + +/** + * Example 10: Generic Error Handler + * Demonstrates a reusable error handler for all rclnodejs errors + */ +function handleRclError(error, operation) { + if (!(error instanceof rclnodejs.RclNodeError)) { + console.error(`Unexpected error in ${operation}:`, error.message); + return; + } + + const context = []; + if (error.nodeName) context.push(`node: ${error.nodeName}`); + if (error.entityName) + context.push(`${error.entityType}: ${error.entityName}`); + + console.error( + `${error.name} in ${operation}${ + context.length ? ` (${context.join(', ')})` : '' + }: ${error.message}` + ); + + if (error instanceof rclnodejs.TimeoutError) { + console.error(` Timeout: ${error.timeout}ms`); + } else if (error instanceof rclnodejs.TypeValidationError) { + console.error(` Expected: ${error.expectedType}`); + } else if (error instanceof rclnodejs.RangeValidationError) { + console.error(` Constraint: ${error.validationRule}`); + } +} + +async function example10_genericHandler() { + console.log('\n=== Example 10: Generic Error Handler ==='); + await rclnodejs.init(); + + try { + rclnodejs.createNode(123); + } catch (error) { + handleRclError(error, 'createNode'); + } + + try { + const node = rclnodejs.createNode('test_node'); + await node.createRate(5000); + } catch (error) { + handleRclError(error, 'createRate'); + } + + rclnodejs.shutdown(); +} + +async function main() { + await example1_typeValidation(); + await example2_rangeValidation(); + await example3_serviceErrors(); + await example4_publisherErrors(); + await example5_subscriptionErrors(); + await example6_parameterErrors(); + await example7_nameValidation(); + await example8_errorRecovery(); + await example9_errorSerialization(); + await example10_genericHandler(); + + console.log('\n✅ All examples completed'); +} + +main().catch(console.error); diff --git a/index.js b/index.js index de3f2e38..906fdf5f 100644 --- a/index.js +++ b/index.js @@ -59,6 +59,7 @@ const { deserializeMessage, } = require('./lib/serialization.js'); const ParameterClient = require('./lib/parameter_client.js'); +const errors = require('./lib/errors.js'); const { spawn } = require('child_process'); /** @@ -560,6 +561,56 @@ let rcl = { * @returns {string} The JSON string representation */ toJSONString: toJSONString, + + // Error classes for structured error handling + /** {@link RclNodeError} - Base error class for all rclnodejs errors */ + RclNodeError: errors.RclNodeError, + + /** {@link ValidationError} - Error thrown when validation fails */ + ValidationError: errors.ValidationError, + /** {@link TypeValidationError} - Type validation error */ + TypeValidationError: errors.TypeValidationError, + /** {@link RangeValidationError} - Range/value validation error */ + RangeValidationError: errors.RangeValidationError, + /** {@link NameValidationError} - ROS name validation error */ + NameValidationError: errors.NameValidationError, + + /** {@link OperationError} - Base class for operation/runtime errors */ + OperationError: errors.OperationError, + /** {@link TimeoutError} - Request timeout error */ + TimeoutError: errors.TimeoutError, + /** {@link AbortError} - Request abortion error */ + AbortError: errors.AbortError, + /** {@link ServiceNotFoundError} - Service not available error */ + ServiceNotFoundError: errors.ServiceNotFoundError, + /** {@link NodeNotFoundError} - Remote node not found error */ + NodeNotFoundError: errors.NodeNotFoundError, + + /** {@link ParameterError} - Base error for parameter operations */ + ParameterError: errors.ParameterError, + /** {@link ParameterNotFoundError} - Parameter not found error */ + ParameterNotFoundError: errors.ParameterNotFoundError, + /** {@link ParameterTypeError} - Parameter type mismatch error */ + ParameterTypeError: errors.ParameterTypeError, + /** {@link ReadOnlyParameterError} - Read-only parameter modification error */ + ReadOnlyParameterError: errors.ReadOnlyParameterError, + + /** {@link TopicError} - Base error for topic operations */ + TopicError: errors.TopicError, + /** {@link PublisherError} - Publisher-specific error */ + PublisherError: errors.PublisherError, + /** {@link SubscriptionError} - Subscription-specific error */ + SubscriptionError: errors.SubscriptionError, + + /** {@link ActionError} - Base error for action operations */ + ActionError: errors.ActionError, + /** {@link GoalRejectedError} - Goal rejected by action server */ + GoalRejectedError: errors.GoalRejectedError, + /** {@link ActionServerNotFoundError} - Action server not found */ + ActionServerNotFoundError: errors.ActionServerNotFoundError, + + /** {@link NativeError} - Wraps errors from native C++ layer */ + NativeError: errors.NativeError, }; const _sigHandler = () => { diff --git a/lib/action/client.js b/lib/action/client.js index df130e29..104d4f19 100644 --- a/lib/action/client.js +++ b/lib/action/client.js @@ -23,6 +23,11 @@ const DistroUtils = require('../distro.js'); const Entity = require('../entity.js'); const loader = require('../interface_loader.js'); const QoS = require('../qos.js'); +const { + TypeValidationError, + ActionError, + OperationError, +} = require('../errors.js'); /** * @class - ROS Action client. @@ -103,7 +108,14 @@ class ActionClient extends Entity { if (goalHandle.accepted) { let uuid = ActionUuid.fromMessage(goalHandle.goalId).toString(); if (this._goalHandles.has(uuid)) { - throw new Error(`Two goals were accepted with the same ID (${uuid})`); + throw new ActionError( + `Two goals were accepted with the same ID (${uuid})`, + this._actionName, + { + code: 'DUPLICATE_GOAL_ID', + details: { goalId: uuid }, + } + ); } this._goalHandles.set(uuid, goalHandle); @@ -210,8 +222,14 @@ class ActionClient extends Entity { ); if (this._pendingGoalRequests.has(sequenceNumber)) { - throw new Error( - `Sequence (${sequenceNumber}) conflicts with pending goal request` + throw new OperationError( + `Sequence (${sequenceNumber}) conflicts with pending goal request`, + { + code: 'SEQUENCE_CONFLICT', + entityType: 'action client', + entityName: this._actionName, + details: { sequenceNumber: sequenceNumber, requestType: 'goal' }, + } ); } @@ -274,7 +292,15 @@ class ActionClient extends Entity { */ _cancelGoal(goalHandle) { if (!(goalHandle instanceof ClientGoalHandle)) { - throw new TypeError('Invalid argument, must be type of ClientGoalHandle'); + throw new TypeValidationError( + 'goalHandle', + goalHandle, + 'ClientGoalHandle', + { + entityType: 'action client', + entityName: this._actionName, + } + ); } let request = new ActionInterfaces.CancelGoal.Request(); @@ -290,8 +316,14 @@ class ActionClient extends Entity { request.serialize() ); if (this._pendingCancelRequests.has(sequenceNumber)) { - throw new Error( - `Sequence (${sequenceNumber}) conflicts with pending cancel request` + throw new OperationError( + `Sequence (${sequenceNumber}) conflicts with pending cancel request`, + { + code: 'SEQUENCE_CONFLICT', + entityType: 'action client', + entityName: this._actionName, + details: { sequenceNumber: sequenceNumber, requestType: 'cancel' }, + } ); } @@ -316,7 +348,15 @@ class ActionClient extends Entity { */ _getResult(goalHandle) { if (!(goalHandle instanceof ClientGoalHandle)) { - throw new TypeError('Invalid argument, must be type of ClientGoalHandle'); + throw new TypeValidationError( + 'goalHandle', + goalHandle, + 'ClientGoalHandle', + { + entityType: 'action client', + entityName: this._actionName, + } + ); } let request = new this.typeClass.impl.GetResultService.Request(); @@ -327,8 +367,14 @@ class ActionClient extends Entity { request.serialize() ); if (this._pendingResultRequests.has(sequenceNumber)) { - throw new Error( - `Sequence (${sequenceNumber}) conflicts with pending result request` + throw new OperationError( + `Sequence (${sequenceNumber}) conflicts with pending result request`, + { + code: 'SEQUENCE_CONFLICT', + entityType: 'action client', + entityName: this._actionName, + details: { sequenceNumber: sequenceNumber, requestType: 'result' }, + } ); } diff --git a/lib/action/deferred.js b/lib/action/deferred.js index 6cbd986b..9c64268d 100644 --- a/lib/action/deferred.js +++ b/lib/action/deferred.js @@ -14,6 +14,8 @@ 'use strict'; +const { TypeValidationError } = require('../errors.js'); + /** * @class - Wraps a promise allowing it to be resolved elsewhere. * @ignore @@ -42,7 +44,9 @@ class Deferred { */ beforeSetResultCallback(callback) { if (typeof callback !== 'function') { - throw new TypeError('Invalid parameter'); + throw new TypeValidationError('callback', callback, 'function', { + entityType: 'deferred promise', + }); } this._beforeSetResultCallback = callback; @@ -70,7 +74,9 @@ class Deferred { */ setDoneCallback(callback) { if (typeof callback !== 'function') { - throw new TypeError('Invalid parameter'); + throw new TypeValidationError('callback', callback, 'function', { + entityType: 'deferred promise', + }); } this._promise.finally(() => callback(this._result)); diff --git a/lib/action/server.js b/lib/action/server.js index 6896427f..2c12ec53 100644 --- a/lib/action/server.js +++ b/lib/action/server.js @@ -23,6 +23,7 @@ const { CancelResponse, GoalEvent, GoalResponse } = require('./response.js'); const loader = require('../interface_loader.js'); const QoS = require('../qos.js'); const ServerGoalHandle = require('./server_goal_handle.js'); +const { TypeValidationError } = require('../errors.js'); /** * Execute the goal. @@ -219,7 +220,15 @@ class ActionServer extends Entity { */ registerExecuteCallback(executeCallback) { if (typeof executeCallback !== 'function') { - throw new TypeError('Invalid argument'); + throw new TypeValidationError( + 'executeCallback', + executeCallback, + 'function', + { + entityType: 'action server', + entityName: this._actionName, + } + ); } this._callback = executeCallback; diff --git a/lib/action/uuid.js b/lib/action/uuid.js index cdd84867..a51a1385 100644 --- a/lib/action/uuid.js +++ b/lib/action/uuid.js @@ -16,6 +16,7 @@ const ActionInterfaces = require('./interfaces.js'); const { randomUUID } = require('crypto'); +const { TypeValidationError } = require('../errors.js'); /** * @class - Represents a unique identifier used by actions. @@ -31,7 +32,9 @@ class ActionUuid { constructor(bytes) { if (bytes) { if (!(bytes instanceof Uint8Array)) { - throw new Error('Invalid parameter'); + throw new TypeValidationError('bytes', bytes, 'Uint8Array', { + entityType: 'action uuid', + }); } this._bytes = bytes; diff --git a/lib/client.js b/lib/client.js index 190dd2a8..17eadcae 100644 --- a/lib/client.js +++ b/lib/client.js @@ -17,6 +17,11 @@ const rclnodejs = require('./native_loader.js'); const DistroUtils = require('./distro.js'); const Entity = require('./entity.js'); +const { + TypeValidationError, + TimeoutError, + AbortError, +} = require('./errors.js'); const debug = require('debug')('rclnodejs:client'); /** @@ -51,7 +56,10 @@ class Client extends Entity { */ sendRequest(request, callback) { if (typeof callback !== 'function') { - throw new TypeError('Invalid argument'); + throw new TypeValidationError('callback', callback, 'function', { + entityType: 'service', + entityName: this._serviceName, + }); } let requestToSend = @@ -72,8 +80,8 @@ class Client extends Entity { * @param {number} [options.timeout] - Timeout in milliseconds for the request. * @param {AbortSignal} [options.signal] - AbortSignal to cancel the request. * @return {Promise} Promise that resolves with the service response. - * @throws {TimeoutError} If the request times out (when options.timeout is exceeded). - * @throws {AbortError} If the request is manually aborted (via options.signal). + * @throws {module:rclnodejs.TimeoutError} If the request times out (when options.timeout is exceeded). + * @throws {module:rclnodejs.AbortError} If the request is manually aborted (via options.signal). * @throws {Error} If the request fails for other reasons. */ sendRequestAsync(request, options = {}) { @@ -107,15 +115,15 @@ class Client extends Entity { if (effectiveSignal) { if (effectiveSignal.aborted) { - const error = new Error( - isTimeout - ? `Request timeout after ${options.timeout}ms` - : 'Request was aborted' - ); - error.name = isTimeout ? 'TimeoutError' : 'AbortError'; - if (isTimeout) { - error.code = 'TIMEOUT'; - } + const error = isTimeout + ? new TimeoutError('Service request', options.timeout, { + entityType: 'service', + entityName: this._serviceName, + }) + : new AbortError('Service request', undefined, { + entityType: 'service', + entityName: this._serviceName, + }); reject(error); return; } @@ -123,15 +131,15 @@ class Client extends Entity { effectiveSignal.addEventListener('abort', () => { if (!isResolved) { cleanup(); - const error = new Error( - isTimeout - ? `Request timeout after ${options.timeout}ms` - : 'Request was aborted' - ); - error.name = isTimeout ? 'TimeoutError' : 'AbortError'; - if (isTimeout) { - error.code = 'TIMEOUT'; - } + const error = isTimeout + ? new TimeoutError('Service request', options.timeout, { + entityType: 'service', + entityName: this._serviceName, + }) + : new AbortError('Service request', undefined, { + entityType: 'service', + entityName: this._serviceName, + }); reject(error); } }); diff --git a/lib/clock.js b/lib/clock.js index ff208cae..19a7c783 100644 --- a/lib/clock.js +++ b/lib/clock.js @@ -17,6 +17,7 @@ const rclnodejs = require('./native_loader.js'); const Time = require('./time.js'); const ClockType = require('./clock_type.js'); +const { TypeValidationError } = require('./errors.js'); /** * @class - Class representing a Clock in ROS @@ -102,7 +103,9 @@ class ROSClock extends Clock { set rosTimeOverride(time) { if (!(time instanceof Time)) { - throw new TypeError('Invalid argument, must be type of Time'); + throw new TypeValidationError('time', time, 'Time', { + entityType: 'clock', + }); } rclnodejs.setRosTimeOverride(this._handle, time._handle); } diff --git a/lib/context.js b/lib/context.js index 4033f91a..98205d59 100644 --- a/lib/context.js +++ b/lib/context.js @@ -15,6 +15,7 @@ 'use strict'; const rclnodejs = require('./native_loader.js'); +const { OperationError } = require('./errors.js'); let defaultContext = null; @@ -184,11 +185,20 @@ class Context { onNodeCreated(node) { if (!node) { - throw new Error('Node must be defined to add to Context'); + throw new OperationError('Node must be defined to add to Context', { + code: 'NODE_UNDEFINED', + entityType: 'context', + }); } if (this.isShutdown()) { - throw new Error('Can not add a Node to a Context that is shutdown'); + throw new OperationError( + 'Can not add a Node to a Context that is shutdown', + { + code: 'CONTEXT_SHUTDOWN', + entityType: 'context', + } + ); } if (this._nodes.includes(node)) { diff --git a/lib/duration.js b/lib/duration.js index d5acd0d2..816faa9e 100644 --- a/lib/duration.js +++ b/lib/duration.js @@ -15,6 +15,7 @@ 'use strict'; const rclnodejs = require('./native_loader.js'); +const { TypeValidationError, RangeValidationError } = require('./errors.js'); const S_TO_NS = 10n ** 9n; /** @@ -29,17 +30,31 @@ class Duration { */ constructor(seconds = 0n, nanoseconds = 0n) { if (typeof seconds !== 'bigint') { - throw new TypeError('Invalid argument of seconds'); + throw new TypeValidationError('seconds', seconds, 'bigint', { + entityType: 'duration', + }); } if (typeof nanoseconds !== 'bigint') { - throw new TypeError('Invalid argument of nanoseconds'); + throw new TypeValidationError('nanoseconds', nanoseconds, 'bigint', { + entityType: 'duration', + }); } const total = seconds * S_TO_NS + nanoseconds; if (total >= 2n ** 63n) { - throw new RangeError( - 'Total nanoseconds value is too large to store in C time point.' + throw new RangeValidationError( + 'total nanoseconds', + total, + '< 2^63 (max C duration)', + { + entityType: 'duration', + details: { + seconds: seconds, + nanoseconds: nanoseconds, + total: total, + }, + } ); } @@ -67,9 +82,9 @@ class Duration { if (other instanceof Duration) { return this._nanoseconds === other.nanoseconds; } - throw new TypeError( - `Can't compare duration with object of type: ${other.constructor.name}` - ); + throw new TypeValidationError('other', other, 'Duration', { + entityType: 'duration', + }); } /** @@ -81,7 +96,9 @@ class Duration { if (other instanceof Duration) { return this._nanoseconds !== other.nanoseconds; } - throw new TypeError('Invalid argument'); + throw new TypeValidationError('other', other, 'Duration', { + entityType: 'duration', + }); } /** @@ -93,7 +110,9 @@ class Duration { if (other instanceof Duration) { return this._nanoseconds < other.nanoseconds; } - throw new TypeError('Invalid argument'); + throw new TypeValidationError('other', other, 'Duration', { + entityType: 'duration', + }); } /** @@ -105,7 +124,9 @@ class Duration { if (other instanceof Duration) { return this._nanoseconds <= other.nanoseconds; } - throw new TypeError('Invalid argument'); + throw new TypeValidationError('other', other, 'Duration', { + entityType: 'duration', + }); } /** @@ -117,7 +138,9 @@ class Duration { if (other instanceof Duration) { return this._nanoseconds > other.nanoseconds; } - throw new TypeError('Invalid argument'); + throw new TypeValidationError('other', other, 'Duration', { + entityType: 'duration', + }); } /** @@ -129,7 +152,9 @@ class Duration { if (other instanceof Duration) { return this._nanoseconds >= other.nanoseconds; } - throw new TypeError('Invalid argument'); + throw new TypeValidationError('other', other, 'Duration', { + entityType: 'duration', + }); } } diff --git a/lib/errors.js b/lib/errors.js new file mode 100644 index 00000000..5673af56 --- /dev/null +++ b/lib/errors.js @@ -0,0 +1,571 @@ +// Copyright (c) 2025 Mahmoud Alghalayini. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +/** + * Base error class for all rclnodejs errors. + * Provides structured error information with context. + * @class + */ +class RclNodeError extends Error { + /** + * @param {string} message - Human-readable error message + * @param {object} [options] - Additional error context + * @param {string} [options.code] - Machine-readable error code (e.g., 'TIMEOUT', 'INVALID_ARGUMENT') + * @param {string} [options.nodeName] - Name of the node where error occurred + * @param {string} [options.entityType] - Type of entity (publisher, subscription, client, etc.) + * @param {string} [options.entityName] - Name of the entity (topic name, service name, etc.) + * @param {Error} [options.cause] - Original error that caused this error + * @param {any} [options.details] - Additional error-specific details + */ + constructor(message, options = {}) { + super(message); + + // Maintains proper stack trace for where our error was thrown + Error.captureStackTrace(this, this.constructor); + + this.name = this.constructor.name; + this.code = options.code || 'UNKNOWN_ERROR'; + this.nodeName = options.nodeName; + this.entityType = options.entityType; + this.entityName = options.entityName; + this.details = options.details; + + // Error chaining (ES2022 feature, Node.js 16.9+) + if (options.cause) { + this.cause = options.cause; + } + + // Timestamp for logging/debugging + this.timestamp = new Date(); + } + + /** + * Returns a detailed error object for logging/serialization + * @return {object} Structured error information + */ + toJSON() { + return { + name: this.name, + message: this.message, + code: this.code, + nodeName: this.nodeName, + entityType: this.entityType, + entityName: this.entityName, + details: this.details, + timestamp: this.timestamp.toISOString(), + stack: this.stack, + cause: this.cause + ? this.cause.toJSON?.() || this.cause.message + : undefined, + }; + } + + /** + * Returns a user-friendly error description + * @return {string} Formatted error string + */ + toString() { + let str = `${this.name}: ${this.message}`; + if (this.code) str += ` [${this.code}]`; + if (this.nodeName) str += ` (node: ${this.nodeName})`; + if (this.entityName) + str += ` (${this.entityType || 'entity'}: ${this.entityName})`; + return str; + } +} + +/** + * Error thrown when validation fails + * @class + * @extends RclNodeError + */ +class ValidationError extends RclNodeError { + /** + * @param {string} message - Error message + * @param {object} [options] - Additional options + * @param {string} [options.argumentName] - Name of the argument that failed validation + * @param {any} [options.providedValue] - The value that was provided + * @param {string} [options.expectedType] - The expected type or format + * @param {string} [options.validationRule] - The validation rule that failed + */ + constructor(message, options = {}) { + super(message, { code: 'VALIDATION_ERROR', ...options }); + this.argumentName = options.argumentName; + this.providedValue = options.providedValue; + this.expectedType = options.expectedType; + this.validationRule = options.validationRule; + } +} + +/** + * Type validation error + * @class + * @extends ValidationError + */ +class TypeValidationError extends ValidationError { + /** + * @param {string} argumentName - Name of the argument + * @param {any} providedValue - The value that was provided + * @param {string} expectedType - The expected type + * @param {object} [options] - Additional options + */ + constructor(argumentName, providedValue, expectedType, options = {}) { + super( + `Invalid type for '${argumentName}': expected ${expectedType}, got ${typeof providedValue}`, + { + code: 'INVALID_TYPE', + argumentName, + providedValue, + expectedType, + ...options, + } + ); + } +} + +/** + * Range/value validation error + * @class + * @extends ValidationError + */ +class RangeValidationError extends ValidationError { + /** + * @param {string} argumentName - Name of the argument + * @param {any} providedValue - The value that was provided + * @param {string} constraint - The constraint that was violated + * @param {object} [options] - Additional options + */ + constructor(argumentName, providedValue, constraint, options = {}) { + super( + `Value '${providedValue}' for '${argumentName}' is out of range: ${constraint}`, + { + code: 'OUT_OF_RANGE', + argumentName, + providedValue, + validationRule: constraint, + ...options, + } + ); + } +} + +/** + * ROS name validation error (topics, nodes, services) + * @class + * @extends ValidationError + */ +class NameValidationError extends ValidationError { + /** + * @param {string} name - The invalid name + * @param {string} nameType - Type of name (node, topic, service, etc.) + * @param {string} validationResult - The validation error message + * @param {number} invalidIndex - Index where validation failed + * @param {object} [options] - Additional options + */ + constructor(name, nameType, validationResult, invalidIndex, options = {}) { + super( + `Invalid ${nameType} name '${name}': ${validationResult}` + + (invalidIndex >= 0 ? ` at index ${invalidIndex}` : ''), + { + code: 'INVALID_NAME', + argumentName: nameType, + providedValue: name, + details: { validationResult, invalidIndex }, + ...options, + } + ); + this.invalidIndex = invalidIndex; + this.validationResult = validationResult; + } +} + +/** + * Base class for operation/runtime errors + * @class + * @extends RclNodeError + */ +class OperationError extends RclNodeError { + /** + * @param {string} message - Error message + * @param {object} [options] - Additional options + */ + constructor(message, options = {}) { + super(message, { code: 'OPERATION_ERROR', ...options }); + } +} + +/** + * Request timeout error + * @class + * @extends OperationError + */ +class TimeoutError extends OperationError { + /** + * @param {string} operationType - Type of operation that timed out + * @param {number} timeoutMs - Timeout duration in milliseconds + * @param {object} [options] - Additional options + */ + constructor(operationType, timeoutMs, options = {}) { + super(`${operationType} timeout after ${timeoutMs}ms`, { + code: 'TIMEOUT', + details: { timeoutMs, operationType }, + ...options, + }); + this.timeout = timeoutMs; + this.operationType = operationType; + } +} + +/** + * Request abortion error + * @class + * @extends OperationError + */ +class AbortError extends OperationError { + /** + * @param {string} operationType - Type of operation that was aborted + * @param {string} [reason] - Reason for abortion + * @param {object} [options] - Additional options + */ + constructor(operationType, reason, options = {}) { + super(`${operationType} was aborted` + (reason ? `: ${reason}` : ''), { + code: 'ABORTED', + details: { operationType, reason }, + ...options, + }); + this.operationType = operationType; + this.abortReason = reason; + } +} + +/** + * Service not available error + * @class + * @extends OperationError + */ +class ServiceNotFoundError extends OperationError { + /** + * @param {string} serviceName - Name of the service + * @param {object} [options] - Additional options + */ + constructor(serviceName, options = {}) { + super(`Service '${serviceName}' is not available`, { + code: 'SERVICE_NOT_FOUND', + entityType: 'service', + entityName: serviceName, + ...options, + }); + this.serviceName = serviceName; + } +} + +/** + * Remote node not found error + * @class + * @extends OperationError + */ +class NodeNotFoundError extends OperationError { + /** + * @param {string} nodeName - Name of the node + * @param {object} [options] - Additional options + */ + constructor(nodeName, options = {}) { + super(`Node '${nodeName}' not found or not available`, { + code: 'NODE_NOT_FOUND', + entityType: 'node', + entityName: nodeName, + ...options, + }); + this.targetNodeName = nodeName; + } +} + +/** + * Base error for parameter operations + * @class + * @extends RclNodeError + */ +class ParameterError extends RclNodeError { + /** + * @param {string} message - Error message + * @param {string} parameterName - Name of the parameter + * @param {object} [options] - Additional options + */ + constructor(message, parameterName, options = {}) { + super(message, { + code: 'PARAMETER_ERROR', + entityType: 'parameter', + entityName: parameterName, + ...options, + }); + this.parameterName = parameterName; + } +} + +/** + * Parameter not found error + * @class + * @extends ParameterError + */ +class ParameterNotFoundError extends ParameterError { + /** + * @param {string} parameterName - Name of the parameter + * @param {string} nodeName - Name of the node + * @param {object} [options] - Additional options + */ + constructor(parameterName, nodeName, options = {}) { + super( + `Parameter '${parameterName}' not found on node '${nodeName}'`, + parameterName, + { + code: 'PARAMETER_NOT_FOUND', + nodeName, + ...options, + } + ); + } +} + +/** + * Parameter type mismatch error + * @class + * @extends ParameterError + */ +class ParameterTypeError extends ParameterError { + /** + * @param {string} parameterName - Name of the parameter + * @param {string} expectedType - Expected parameter type + * @param {string} actualType - Actual parameter type + * @param {object} [options] - Additional options + */ + constructor(parameterName, expectedType, actualType, options = {}) { + super( + `Type mismatch for parameter '${parameterName}': expected ${expectedType}, got ${actualType}`, + parameterName, + { + code: 'PARAMETER_TYPE_MISMATCH', + details: { expectedType, actualType }, + ...options, + } + ); + this.expectedType = expectedType; + this.actualType = actualType; + } +} + +/** + * Read-only parameter modification error + * @class + * @extends ParameterError + */ +class ReadOnlyParameterError extends ParameterError { + /** + * @param {string} parameterName - Name of the parameter + * @param {object} [options] - Additional options + */ + constructor(parameterName, options = {}) { + super( + `Cannot modify read-only parameter '${parameterName}'`, + parameterName, + { + code: 'PARAMETER_READ_ONLY', + ...options, + } + ); + } +} + +/** + * Base error for topic operations + * @class + * @extends RclNodeError + */ +class TopicError extends RclNodeError { + /** + * @param {string} message - Error message + * @param {string} topicName - Name of the topic + * @param {object} [options] - Additional options + */ + constructor(message, topicName, options = {}) { + super(message, { + code: 'TOPIC_ERROR', + entityType: 'topic', + entityName: topicName, + ...options, + }); + this.topicName = topicName; + } +} + +/** + * Publisher-specific error + * @class + * @extends TopicError + */ +class PublisherError extends TopicError { + /** + * @param {string} message - Error message + * @param {string} topicName - Name of the topic + * @param {object} [options] - Additional options + */ + constructor(message, topicName, options = {}) { + super(message, topicName, { + code: 'PUBLISHER_ERROR', + entityType: 'publisher', + ...options, + }); + } +} + +/** + * Subscription-specific error + * @class + * @extends TopicError + */ +class SubscriptionError extends TopicError { + /** + * @param {string} message - Error message + * @param {string} topicName - Name of the topic + * @param {object} [options] - Additional options + */ + constructor(message, topicName, options = {}) { + super(message, topicName, { + code: 'SUBSCRIPTION_ERROR', + entityType: 'subscription', + ...options, + }); + } +} + +/** + * Base error for action operations + * @class + * @extends RclNodeError + */ +class ActionError extends RclNodeError { + /** + * @param {string} message - Error message + * @param {string} actionName - Name of the action + * @param {object} [options] - Additional options + */ + constructor(message, actionName, options = {}) { + super(message, { + code: 'ACTION_ERROR', + entityType: 'action', + entityName: actionName, + ...options, + }); + this.actionName = actionName; + } +} + +/** + * Goal rejected by action server + * @class + * @extends ActionError + */ +class GoalRejectedError extends ActionError { + /** + * @param {string} actionName - Name of the action + * @param {string} goalId - ID of the rejected goal + * @param {object} [options] - Additional options + */ + constructor(actionName, goalId, options = {}) { + super(`Goal rejected by action server '${actionName}'`, actionName, { + code: 'GOAL_REJECTED', + details: { goalId }, + ...options, + }); + this.goalId = goalId; + } +} + +/** + * Action server not found + * @class + * @extends ActionError + */ +class ActionServerNotFoundError extends ActionError { + /** + * @param {string} actionName - Name of the action + * @param {object} [options] - Additional options + */ + constructor(actionName, options = {}) { + super(`Action server '${actionName}' is not available`, actionName, { + code: 'ACTION_SERVER_NOT_FOUND', + ...options, + }); + } +} + +/** + * Wraps errors from native C++ layer with additional context + * @class + * @extends RclNodeError + */ +class NativeError extends RclNodeError { + /** + * @param {string} nativeMessage - Error message from C++ layer + * @param {string} operation - Operation that failed + * @param {object} [options] - Additional options + */ + constructor(nativeMessage, operation, options = {}) { + super(`Native operation failed: ${operation} - ${nativeMessage}`, { + code: 'NATIVE_ERROR', + details: { nativeMessage, operation }, + ...options, + }); + this.nativeMessage = nativeMessage; + this.operation = operation; + } +} + +module.exports = { + // Base error + RclNodeError, + + // Validation errors + ValidationError, + TypeValidationError, + RangeValidationError, + NameValidationError, + + // Operation errors + OperationError, + TimeoutError, + AbortError, + ServiceNotFoundError, + NodeNotFoundError, + + // Parameter errors + ParameterError, + ParameterNotFoundError, + ParameterTypeError, + ReadOnlyParameterError, + + // Topic errors + TopicError, + PublisherError, + SubscriptionError, + + // Action errors + ActionError, + GoalRejectedError, + ActionServerNotFoundError, + + // Native error + NativeError, +}; diff --git a/lib/event_handler.js b/lib/event_handler.js index b2b506e0..b4adb961 100644 --- a/lib/event_handler.js +++ b/lib/event_handler.js @@ -16,6 +16,7 @@ const rclnodejs = require('./native_loader.js'); const DistroUtils = require('./distro.js'); +const { OperationError } = require('./errors.js'); const Entity = require('./entity.js'); /** @@ -79,8 +80,16 @@ class EventHandler extends Entity { class PublisherEventCallbacks { constructor() { if (DistroUtils.getDistroId() < DistroUtils.getDistroId('jazzy')) { - throw new Error( - 'PublisherEventCallbacks is only available in ROS 2 Jazzy and later.' + throw new OperationError( + 'PublisherEventCallbacks is only available in ROS 2 Jazzy and later', + { + code: 'UNSUPPORTED_ROS_VERSION', + entityType: 'publisher event callbacks', + details: { + requiredVersion: 'jazzy', + currentVersion: DistroUtils.getDistroId(), + }, + } ); } this._deadline = null; @@ -262,8 +271,16 @@ class PublisherEventCallbacks { class SubscriptionEventCallbacks { constructor() { if (DistroUtils.getDistroId() < DistroUtils.getDistroId('jazzy')) { - throw new Error( - 'SubscriptionEventCallbacks is only available in ROS 2 Jazzy and later.' + throw new OperationError( + 'SubscriptionEventCallbacks is only available in ROS 2 Jazzy and later', + { + code: 'UNSUPPORTED_ROS_VERSION', + entityType: 'subscription event callbacks', + details: { + requiredVersion: 'jazzy', + currentVersion: DistroUtils.getDistroId(), + }, + } ); } this._deadline = null; diff --git a/lib/interface_loader.js b/lib/interface_loader.js index 9a4283c8..94accd9f 100644 --- a/lib/interface_loader.js +++ b/lib/interface_loader.js @@ -17,6 +17,7 @@ const path = require('path'); const fs = require('fs'); const generator = require('../rosidl_gen/index.js'); +const { TypeValidationError, ValidationError } = require('./errors.js'); let interfaceLoader = { loadInterfaceByObject(obj) { @@ -36,8 +37,13 @@ let interfaceLoader = { !obj.type || !obj.name ) { - throw new TypeError( - 'Should provide an object argument to get ROS message class' + throw new TypeValidationError( + 'interfaceObject', + obj, + 'object with package, type, and name properties', + { + entityType: 'interface loader', + } ); } return this.loadInterface(obj.package, obj.type, obj.name); @@ -45,9 +51,9 @@ let interfaceLoader = { loadInterfaceByString(name) { if (typeof name !== 'string') { - throw new TypeError( - 'Should provide a string argument to get ROS message class' - ); + throw new TypeValidationError('name', name, 'string', { + entityType: 'interface loader', + }); } // TODO(Kenny): more checks of the string argument @@ -64,8 +70,15 @@ let interfaceLoader = { return this.loadInterfaceByPath(packagePath, interfaces); } - throw new TypeError( - 'A string argument in expected in "package/type/message" format' + throw new ValidationError( + 'A string argument in expected in "package/type/message" format', + { + code: 'INVALID_INTERFACE_FORMAT', + argumentName: 'name', + providedValue: name, + expectedType: 'string in "package/type/message" format', + entityType: 'interface loader', + } ); }, @@ -174,8 +187,18 @@ let interfaceLoader = { } } } - throw new Error( - `The message required does not exist: ${packageName}, ${type}, ${messageName} at ${generator.generatedRoot}` + throw new ValidationError( + `The message required does not exist: ${packageName}, ${type}, ${messageName} at ${generator.generatedRoot}`, + { + code: 'MESSAGE_NOT_FOUND', + entityType: 'interface loader', + details: { + packageName: packageName, + type: type, + messageName: messageName, + searchPath: generator.generatedRoot, + }, + } ); }, @@ -187,7 +210,14 @@ let interfaceLoader = { } else if (typeof type === 'string') { return this.loadInterfaceByString(type); } - throw new Error(`The message required does not exist: ${type}`); + throw new ValidationError( + `The message required does not exist: ${type}`, + { + code: 'MESSAGE_NOT_FOUND', + entityType: 'interface loader', + details: { type: type }, + } + ); } if (packageName && type && messageName) { let filePath = path.join( @@ -208,8 +238,18 @@ let interfaceLoader = { } } // We cannot parse `packageName`, `type` and `messageName` from the string passed. - throw new Error( - `The message required does not exist: ${packageName}, ${type}, ${messageName} at ${generator.generatedRoot}` + throw new ValidationError( + `The message required does not exist: ${packageName}, ${type}, ${messageName} at ${generator.generatedRoot}`, + { + code: 'MESSAGE_NOT_FOUND', + entityType: 'interface loader', + details: { + packageName: packageName, + type: type, + messageName: messageName, + searchPath: generator.generatedRoot, + }, + } ); }, }; diff --git a/lib/lifecycle.js b/lib/lifecycle.js index cee8e8bd..517f926f 100644 --- a/lib/lifecycle.js +++ b/lib/lifecycle.js @@ -21,6 +21,7 @@ const Context = require('./context.js'); const Node = require('./node.js'); const NodeOptions = require('./node_options.js'); const Service = require('./service.js'); +const { ValidationError } = require('./errors.js'); const SHUTDOWN_TRANSITION_LABEL = rclnodejs.getLifecycleShutdownTransitionLabel(); @@ -707,8 +708,13 @@ class LifecycleNode extends Node { ); if (!newStateObj) { - throw new Error( - `No transition available from state ${transitionIdOrLabel}.` + throw new ValidationError( + `No transition available from state ${transitionIdOrLabel}`, + { + code: 'INVALID_TRANSITION', + entityType: 'lifecycle node', + details: { transitionIdOrLabel: transitionIdOrLabel }, + } ); } diff --git a/lib/logging.js b/lib/logging.js index 500f190c..ae47aa2d 100644 --- a/lib/logging.js +++ b/lib/logging.js @@ -16,6 +16,7 @@ const path = require('path'); const rclnodejs = require('./native_loader.js'); +const { TypeValidationError } = require('./errors.js'); /** * Enum for LoggingSeverity @@ -98,7 +99,10 @@ class Logging { */ setLoggerLevel(level) { if (typeof level !== 'number') { - throw new TypeError('Invalid argument'); + throw new TypeValidationError('level', level, 'number', { + entityType: 'logger', + entityName: this._name, + }); } rclnodejs.setLoggerLevel(this._name, level); } @@ -164,7 +168,10 @@ class Logging { _log(message, severity) { if (typeof message !== 'string') { - throw new TypeError('Invalid argument'); + throw new TypeValidationError('message', message, 'string', { + entityType: 'logger', + entityName: this._name, + }); } let caller = new Caller(); @@ -204,7 +211,9 @@ class Logging { */ static getLogger(name) { if (typeof name !== 'string') { - throw new TypeError('Invalid argument'); + throw new TypeValidationError('name', name, 'string', { + entityType: 'logger', + }); } return new Logging(name); } diff --git a/lib/message_serialization.js b/lib/message_serialization.js index b1b93a63..295e1eb0 100644 --- a/lib/message_serialization.js +++ b/lib/message_serialization.js @@ -14,6 +14,8 @@ 'use strict'; +const { ValidationError } = require('./errors.js'); + /** * Check if a value is a TypedArray * @param {*} value - The value to check @@ -145,8 +147,14 @@ function applySerializationMode(message, serializationMode) { return toJSONSafe(message); default: - throw new TypeError( - `Invalid serializationMode: ${serializationMode}. Valid modes are: 'default', 'plain', 'json'` + throw new ValidationError( + `Invalid serializationMode: ${serializationMode}. Valid modes are: 'default', 'plain', 'json'`, + { + code: 'INVALID_SERIALIZATION_MODE', + argumentName: 'serializationMode', + providedValue: serializationMode, + expectedType: "'default' | 'plain' | 'json'", + } ); } } diff --git a/lib/native_loader.js b/lib/native_loader.js index 866a8ca7..2f401229 100644 --- a/lib/native_loader.js +++ b/lib/native_loader.js @@ -17,6 +17,7 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); +const { NativeError } = require('./errors.js'); const bindings = require('bindings'); const debug = require('debug')('rclnodejs'); const { detectUbuntuCodename } = require('./utils'); @@ -100,8 +101,10 @@ function loadNativeAddon() { return nativeModule; } catch (compileError) { debug('Forced compilation failed:', compileError.message); - throw new Error( - `Failed to force build rclnodejs from source: ${compileError.message}` + throw new NativeError( + `Failed to force build rclnodejs from source: ${compileError.message}`, + 'Forced compilation', + { cause: compileError } ); } } @@ -163,8 +166,10 @@ function loadNativeAddon() { return nativeModule; } catch (compileError) { debug('Compilation failed:', compileError.message); - throw new Error( - `Failed to build rclnodejs from source: ${compileError.message}` + throw new NativeError( + `Failed to build rclnodejs from source: ${compileError.message}`, + 'Compilation', + { cause: compileError } ); } } diff --git a/lib/node.js b/lib/node.js index 412eb8df..6937f5bc 100644 --- a/lib/node.js +++ b/lib/node.js @@ -32,6 +32,11 @@ const { ParameterDescriptor, } = require('./parameter.js'); const { isValidSerializationMode } = require('./message_serialization.js'); +const { + TypeValidationError, + RangeValidationError, + ValidationError, +} = require('./errors.js'); const ParameterService = require('./parameter_service.js'); const ParameterClient = require('./parameter_client.js'); const Publisher = require('./publisher.js'); @@ -75,8 +80,11 @@ class Node extends rclnodejs.ShadowNode { ) { super(); - if (typeof nodeName !== 'string' || typeof namespace !== 'string') { - throw new TypeError('Invalid argument.'); + if (typeof nodeName !== 'string') { + throw new TypeValidationError('nodeName', nodeName, 'string'); + } + if (typeof namespace !== 'string') { + throw new TypeValidationError('namespace', namespace, 'string'); } this._init(nodeName, namespace, options, context, args, useGlobalArguments); @@ -135,8 +143,13 @@ class Node extends rclnodejs.ShadowNode { if (options.parameterOverrides.length > 0) { for (const parameter of options.parameterOverrides) { if ((!parameter) instanceof Parameter) { - throw new TypeError( - 'Parameter-override must be an instance of Parameter.' + throw new TypeValidationError( + 'parameterOverride', + parameter, + 'Parameter instance', + { + nodeName: name, + } ); } this._parameterOverrides.set(parameter.name, parameter); @@ -516,7 +529,9 @@ class Node extends rclnodejs.ShadowNode { options !== undefined && (options === null || typeof options !== 'object') ) { - throw new TypeError('Invalid argument of options'); + throw new TypeValidationError('options', options, 'object', { + nodeName: this.name(), + }); } if (options === undefined) { @@ -538,8 +553,15 @@ class Node extends rclnodejs.ShadowNode { if (options.serializationMode === undefined) { options = Object.assign(options, { serializationMode: 'default' }); } else if (!isValidSerializationMode(options.serializationMode)) { - throw new TypeError( - `Invalid serializationMode: ${options.serializationMode}. Valid modes are: 'default', 'plain', 'json'` + throw new ValidationError( + `Invalid serializationMode: ${options.serializationMode}. Valid modes are: 'default', 'plain', 'json'`, + { + code: 'INVALID_SERIALIZATION_MODE', + argumentName: 'serializationMode', + providedValue: options.serializationMode, + expectedType: "'default' | 'plain' | 'json'", + nodeName: this.name(), + } ); } @@ -560,8 +582,15 @@ class Node extends rclnodejs.ShadowNode { clock = arguments[3]; } - if (typeof period !== 'bigint' || typeof callback !== 'function') { - throw new TypeError('Invalid argument'); + if (typeof period !== 'bigint') { + throw new TypeValidationError('period', period, 'bigint', { + nodeName: this.name(), + }); + } + if (typeof callback !== 'function') { + throw new TypeValidationError('callback', callback, 'function', { + nodeName: this.name(), + }); } const timerClock = clock || this._clock; @@ -586,13 +615,20 @@ class Node extends rclnodejs.ShadowNode { */ async createRate(hz = 1) { if (typeof hz !== 'number') { - throw new TypeError('Invalid argument'); + throw new TypeValidationError('hz', hz, 'number', { + nodeName: this.name(), + }); } const MAX_RATE_HZ_IN_MILLISECOND = 1000.0; if (hz <= 0.0 || hz > MAX_RATE_HZ_IN_MILLISECOND) { - throw new RangeError( - `Hz must be between 0.0 and ${MAX_RATE_HZ_IN_MILLISECOND}` + throw new RangeValidationError( + 'hz', + hz, + `0.0 < hz <= ${MAX_RATE_HZ_IN_MILLISECOND}`, + { + nodeName: this.name(), + } ); } @@ -637,12 +673,32 @@ class Node extends rclnodejs.ShadowNode { } options = this._validateOptions(options); + if (typeof typeClass !== 'function') { + throw new TypeValidationError('typeClass', typeClass, 'function', { + nodeName: this.name(), + entityType: 'publisher', + }); + } + if (typeof topic !== 'string') { + throw new TypeValidationError('topic', topic, 'string', { + nodeName: this.name(), + entityType: 'publisher', + }); + } if ( - typeof typeClass !== 'function' || - typeof topic !== 'string' || - (eventCallbacks && !(eventCallbacks instanceof PublisherEventCallbacks)) + eventCallbacks && + !(eventCallbacks instanceof PublisherEventCallbacks) ) { - throw new TypeError('Invalid argument'); + throw new TypeValidationError( + 'eventCallbacks', + eventCallbacks, + 'PublisherEventCallbacks', + { + nodeName: this.name(), + entityType: 'publisher', + entityName: topic, + } + ); } let publisher = publisherClass.createPublisher( @@ -706,14 +762,39 @@ class Node extends rclnodejs.ShadowNode { } options = this._validateOptions(options); + if (typeof typeClass !== 'function') { + throw new TypeValidationError('typeClass', typeClass, 'function', { + nodeName: this.name(), + entityType: 'subscription', + }); + } + if (typeof topic !== 'string') { + throw new TypeValidationError('topic', topic, 'string', { + nodeName: this.name(), + entityType: 'subscription', + }); + } + if (typeof callback !== 'function') { + throw new TypeValidationError('callback', callback, 'function', { + nodeName: this.name(), + entityType: 'subscription', + entityName: topic, + }); + } if ( - typeof typeClass !== 'function' || - typeof topic !== 'string' || - typeof callback !== 'function' || - (eventCallbacks && - !(eventCallbacks instanceof SubscriptionEventCallbacks)) + eventCallbacks && + !(eventCallbacks instanceof SubscriptionEventCallbacks) ) { - throw new TypeError('Invalid argument'); + throw new TypeValidationError( + 'eventCallbacks', + eventCallbacks, + 'SubscriptionEventCallbacks', + { + nodeName: this.name(), + entityType: 'subscription', + entityName: topic, + } + ); } let subscription = Subscription.createSubscription( @@ -748,8 +829,17 @@ class Node extends rclnodejs.ShadowNode { } options = this._validateOptions(options); - if (typeof typeClass !== 'function' || typeof serviceName !== 'string') { - throw new TypeError('Invalid argument'); + if (typeof typeClass !== 'function') { + throw new TypeValidationError('typeClass', typeClass, 'function', { + nodeName: this.name(), + entityType: 'client', + }); + } + if (typeof serviceName !== 'string') { + throw new TypeValidationError('serviceName', serviceName, 'string', { + nodeName: this.name(), + entityType: 'client', + }); } let client = Client.createClient( @@ -803,12 +893,24 @@ class Node extends rclnodejs.ShadowNode { } options = this._validateOptions(options); - if ( - typeof typeClass !== 'function' || - typeof serviceName !== 'string' || - typeof callback !== 'function' - ) { - throw new TypeError('Invalid argument'); + if (typeof typeClass !== 'function') { + throw new TypeValidationError('typeClass', typeClass, 'function', { + nodeName: this.name(), + entityType: 'service', + }); + } + if (typeof serviceName !== 'string') { + throw new TypeValidationError('serviceName', serviceName, 'string', { + nodeName: this.name(), + entityType: 'service', + }); + } + if (typeof callback !== 'function') { + throw new TypeValidationError('callback', callback, 'function', { + nodeName: this.name(), + entityType: 'service', + entityName: serviceName, + }); } let service = Service.createService( @@ -854,7 +956,10 @@ class Node extends rclnodejs.ShadowNode { */ createGuardCondition(callback) { if (typeof callback !== 'function') { - throw new TypeError('Invalid argument'); + throw new TypeValidationError('callback', callback, 'function', { + nodeName: this.name(), + entityType: 'guard_condition', + }); } let guard = GuardCondition.createGuardCondition(callback, this.context); @@ -909,7 +1014,14 @@ class Node extends rclnodejs.ShadowNode { */ destroyPublisher(publisher) { if (!(publisher instanceof Publisher)) { - throw new TypeError('Invalid argument'); + throw new TypeValidationError( + 'publisher', + publisher, + 'Publisher instance', + { + nodeName: this.name(), + } + ); } if (publisher.events) { publisher.events.forEach((event) => { @@ -927,7 +1039,14 @@ class Node extends rclnodejs.ShadowNode { */ destroySubscription(subscription) { if (!(subscription instanceof Subscription)) { - throw new TypeError('Invalid argument'); + throw new TypeValidationError( + 'subscription', + subscription, + 'Subscription instance', + { + nodeName: this.name(), + } + ); } if (subscription.events) { subscription.events.forEach((event) => { @@ -946,7 +1065,9 @@ class Node extends rclnodejs.ShadowNode { */ destroyClient(client) { if (!(client instanceof Client)) { - throw new TypeError('Invalid argument'); + throw new TypeValidationError('client', client, 'Client instance', { + nodeName: this.name(), + }); } this._destroyEntity(client, this._clients); } @@ -958,7 +1079,9 @@ class Node extends rclnodejs.ShadowNode { */ destroyService(service) { if (!(service instanceof Service)) { - throw new TypeError('Invalid argument'); + throw new TypeValidationError('service', service, 'Service instance', { + nodeName: this.name(), + }); } this._destroyEntity(service, this._services); } @@ -983,7 +1106,9 @@ class Node extends rclnodejs.ShadowNode { */ destroyTimer(timer) { if (!(timer instanceof Timer)) { - throw new TypeError('Invalid argument'); + throw new TypeValidationError('timer', timer, 'Timer instance', { + nodeName: this.name(), + }); } this._destroyEntity(timer, this._timers); } @@ -995,7 +1120,9 @@ class Node extends rclnodejs.ShadowNode { */ destroyGuardCondition(guard) { if (!(guard instanceof GuardCondition)) { - throw new TypeError('Invalid argument'); + throw new TypeValidationError('guard', guard, 'GuardCondition instance', { + nodeName: this.name(), + }); } this._destroyEntity(guard, this._guards); } @@ -1335,16 +1462,25 @@ class Node extends rclnodejs.ShadowNode { */ declareParameters(parameters, descriptors = [], ignoreOverrides = false) { if (!Array.isArray(parameters)) { - throw new TypeError('Invalid parameter: expected array of Parameter'); + throw new TypeValidationError('parameters', parameters, 'Array', { + nodeName: this.name(), + }); } if (!Array.isArray(descriptors)) { - throw new TypeError( - 'Invalid parameters: expected array of ParameterDescriptor' - ); + throw new TypeValidationError('descriptors', descriptors, 'Array', { + nodeName: this.name(), + }); } if (descriptors.length > 0 && parameters.length !== descriptors.length) { - throw new TypeError( - 'Each parameter must have a cooresponding ParameterDescriptor' + throw new ValidationError( + 'Each parameter must have a corresponding ParameterDescriptor', + { + code: 'PARAMETER_DESCRIPTOR_MISMATCH', + argumentName: 'descriptors', + providedValue: descriptors.length, + expectedType: `Array with length ${parameters.length}`, + nodeName: this.name(), + } ); } @@ -1784,7 +1920,9 @@ class Node extends rclnodejs.ShadowNode { */ resolveTopicName(topicName, onlyExpand = false) { if (typeof topicName !== 'string') { - throw new TypeError('Invalid argument: expected string'); + throw new TypeValidationError('topicName', topicName, 'string', { + nodeName: this.name(), + }); } return rclnodejs.resolveName( this.handle, @@ -1802,7 +1940,9 @@ class Node extends rclnodejs.ShadowNode { */ resolveServiceName(service, onlyExpand = false) { if (typeof service !== 'string') { - throw new TypeError('Invalid argument: expected string'); + throw new TypeValidationError('service', service, 'string', { + nodeName: this.name(), + }); } return rclnodejs.resolveName( this.handle, diff --git a/lib/parameter.js b/lib/parameter.js index 8e2381a0..5316e5fa 100644 --- a/lib/parameter.js +++ b/lib/parameter.js @@ -20,6 +20,12 @@ 'use strict'; const { isClose } = require('./utils.js'); +const { + TypeValidationError, + RangeValidationError, + ParameterError, + ParameterTypeError, +} = require('./errors.js'); /** * The plus/minus tolerance for determining number equivalence. @@ -184,17 +190,25 @@ class Parameter { typeof this.name !== 'string' || this.name.trim().length === 0 ) { - throw new TypeError('Invalid name'); + throw new TypeValidationError('name', this.name, 'non-empty string', { + entityType: 'parameter', + }); } // validate type if (!validType(this.type)) { - throw new TypeError('Invalid type'); + throw new ParameterError('Invalid parameter type', this.name, { + details: { providedType: this.type }, + }); } // validate value if (!validValue(this.value, this.type)) { - throw new TypeError('Incompatible value.'); + throw new ParameterTypeError(this.name, this.type, typeof this.value, { + details: { + providedValue: this.value, + }, + }); } this._dirty = false; @@ -383,10 +397,23 @@ class ParameterDescriptor { return; } if (!(range instanceof Range)) { - throw TypeError('Expected instance of Range.'); + throw new TypeValidationError('range', range, 'Range', { + entityType: 'parameter descriptor', + parameterName: this.name, + }); } if (!range.isValidType(this.type)) { - throw TypeError('Incompatible Range'); + throw new ParameterError( + 'Incompatible Range for parameter type', + this.name, + { + entityType: 'parameter descriptor', + details: { + rangeType: range.constructor.name, + parameterType: this.type, + }, + } + ); } this._range = range; @@ -405,22 +432,40 @@ class ParameterDescriptor { typeof this.name !== 'string' || this.name.trim().length === 0 ) { - throw new TypeError('Invalid name'); + throw new TypeValidationError('name', this.name, 'non-empty string', { + entityType: 'parameter descriptor', + }); } // validate type if (!validType(this.type)) { - throw new TypeError('Invalid type'); + throw new ParameterError('Invalid parameter type', this.name, { + entityType: 'parameter descriptor', + details: { providedType: this.type }, + }); } // validate description if (this.description && typeof this.description !== 'string') { - throw new TypeError('Invalid description'); + throw new TypeValidationError('description', this.description, 'string', { + entityType: 'parameter descriptor', + parameterName: this.name, + }); } // validate rangeConstraint if (this.hasRange() && !this.range.isValidType(this.type)) { - throw new TypeError('Incompatible Range'); + throw new ParameterError( + 'Incompatible Range for parameter type', + this.name, + { + entityType: 'parameter descriptor', + details: { + rangeType: this.range.constructor.name, + parameterType: this.type, + }, + } + ); } } @@ -433,27 +478,66 @@ class ParameterDescriptor { */ validateParameter(parameter) { if (!parameter) { - throw new TypeError('Parameter is undefined'); + throw new TypeValidationError('parameter', parameter, 'Parameter', { + entityType: 'parameter descriptor', + parameterName: this.name, + }); } // ensure parameter is valid try { parameter.validate(); - } catch { - throw new TypeError('Parameter is invalid'); + } catch (err) { + throw new ParameterError('Parameter is invalid', parameter.name, { + cause: err, + details: { validationError: err.message }, + }); } // ensure this descriptor is valid try { this.validate(); - } catch { - throw new Error('Descriptor is invalid.'); + } catch (err) { + throw new ParameterError('Descriptor is invalid', this.name, { + entityType: 'parameter descriptor', + cause: err, + details: { validationError: err.message }, + }); } - if (this.name !== parameter.name) throw new Error('Name mismatch'); - if (this.type !== parameter.type) throw new Error('Type mismatch'); + if (this.name !== parameter.name) { + throw new ParameterError('Name mismatch', this.name, { + details: { + descriptorName: this.name, + parameterName: parameter.name, + }, + }); + } + if (this.type !== parameter.type) { + throw new ParameterTypeError(this.name, this.type, parameter.type, { + details: { + expectedType: this.type, + actualType: parameter.type, + }, + }); + } if (this.hasRange() && !this.range.inRange(parameter.value)) { - throw new RangeError('Parameter value is not in descriptor range'); + throw new RangeValidationError( + 'value', + parameter.value, + `${this.range.fromValue} to ${this.range.toValue}`, + { + entityType: 'parameter', + parameterName: parameter.name, + details: { + range: { + from: this.range.fromValue, + to: this.range.toValue, + step: this.range.step, + }, + }, + } + ); } } @@ -550,7 +634,9 @@ class Range { true ); } else if (typeof value !== 'number' && typeof value !== 'bigint') { - throw new TypeError('Value must be a number or bigint'); + throw new TypeValidationError('value', value, 'number or bigint', { + entityType: 'range', + }); } return true; @@ -754,7 +840,19 @@ function parameterTypeFromValue(value) { } // Unrecognized value type - throw new TypeError('Unrecognized parameter type.'); + throw new TypeValidationError('value', value, 'valid parameter type', { + entityType: 'parameter', + details: { + supportedTypes: [ + 'boolean', + 'string', + 'number', + 'boolean[]', + 'number[]', + 'string[]', + ], + }, + }); } /** diff --git a/lib/parameter_client.js b/lib/parameter_client.js index fc4b4777..b707f29b 100644 --- a/lib/parameter_client.js +++ b/lib/parameter_client.js @@ -19,6 +19,11 @@ const { ParameterType, parameterTypeFromValue, } = require('./parameter.js'); +const { + TypeValidationError, + ParameterNotFoundError, + OperationError, +} = require('./errors.js'); const validator = require('./validator.js'); const debug = require('debug')('rclnodejs:parameter_client'); @@ -42,10 +47,14 @@ class ParameterClient { */ constructor(node, remoteNodeName, options = {}) { if (!node) { - throw new TypeError('Node is required'); + throw new TypeValidationError('node', node, 'Node instance'); } if (!remoteNodeName || typeof remoteNodeName !== 'string') { - throw new TypeError('Remote node name must be a non-empty string'); + throw new TypeValidationError( + 'remoteNodeName', + remoteNodeName, + 'non-empty string' + ); } this.#node = node; @@ -83,9 +92,7 @@ class ParameterClient { const parameters = await this.getParameters([name], options); if (parameters.length === 0) { - throw new Error( - `Parameter '${name}' not found on node '${this.#remoteNodeName}'` - ); + throw new ParameterNotFoundError(name, this.#remoteNodeName); } return parameters[0]; @@ -104,7 +111,7 @@ class ParameterClient { this.#throwErrorIfClientDestroyed(); if (!Array.isArray(names) || names.length === 0) { - throw new TypeError('Names must be a non-empty array'); + throw new TypeValidationError('names', names, 'non-empty array'); } const client = this.#getOrCreateClient('GetParameters'); @@ -167,7 +174,11 @@ class ParameterClient { this.#throwErrorIfClientDestroyed(); if (!Array.isArray(parameters) || parameters.length === 0) { - throw new TypeError('Parameters must be a non-empty array'); + throw new TypeValidationError( + 'parameters', + parameters, + 'non-empty array' + ); } const client = this.#getOrCreateClient('SetParameters'); @@ -248,7 +259,7 @@ class ParameterClient { this.#throwErrorIfClientDestroyed(); if (!Array.isArray(names) || names.length === 0) { - throw new TypeError('Names must be a non-empty array'); + throw new TypeValidationError('names', names, 'non-empty array'); } const client = this.#getOrCreateClient('DescribeParameters'); @@ -280,7 +291,7 @@ class ParameterClient { this.#throwErrorIfClientDestroyed(); if (!Array.isArray(names) || names.length === 0) { - throw new TypeError('Names must be a non-empty array'); + throw new TypeValidationError('names', names, 'non-empty array'); } const client = this.#getOrCreateClient('GetParameterTypes'); @@ -492,7 +503,11 @@ class ParameterClient { */ #throwErrorIfClientDestroyed() { if (this.#destroyed) { - throw new Error('ParameterClient has been destroyed'); + throw new OperationError('ParameterClient has been destroyed', { + code: 'CLIENT_DESTROYED', + entityType: 'parameter_client', + entityName: this.#remoteNodeName, + }); } } } diff --git a/lib/qos.js b/lib/qos.js index d6e0412b..e5c8123e 100644 --- a/lib/qos.js +++ b/lib/qos.js @@ -14,6 +14,8 @@ 'use strict'; +const { TypeValidationError } = require('./errors.js'); + /** * Enum for HistoryPolicy * @readonly @@ -129,7 +131,9 @@ class QoS { */ set history(history) { if (typeof history !== 'number') { - throw new TypeError('Invalid argument'); + throw new TypeValidationError('history', history, 'number', { + entityType: 'qos', + }); } this._history = history; @@ -154,7 +158,9 @@ class QoS { */ set depth(depth) { if (typeof depth !== 'number') { - throw new TypeError('Invalid argument'); + throw new TypeValidationError('depth', depth, 'number', { + entityType: 'qos', + }); } this._depth = depth; @@ -179,7 +185,9 @@ class QoS { */ set reliability(reliability) { if (typeof reliability !== 'number') { - throw new TypeError('Invalid argument'); + throw new TypeValidationError('reliability', reliability, 'number', { + entityType: 'qos', + }); } this._reliability = reliability; @@ -204,7 +212,9 @@ class QoS { */ set durability(durability) { if (typeof durability !== 'number') { - throw new TypeError('Invalid argument'); + throw new TypeValidationError('durability', durability, 'number', { + entityType: 'qos', + }); } this._durability = durability; @@ -229,7 +239,14 @@ class QoS { */ set avoidRosNameSpaceConventions(avoidRosNameSpaceConventions) { if (typeof avoidRosNameSpaceConventions !== 'boolean') { - throw new TypeError('Invalid argument'); + throw new TypeValidationError( + 'avoidRosNameSpaceConventions', + avoidRosNameSpaceConventions, + 'boolean', + { + entityType: 'qos', + } + ); } this._avoidRosNameSpaceConventions = avoidRosNameSpaceConventions; diff --git a/lib/rate.js b/lib/rate.js index ffe6c660..c065d353 100644 --- a/lib/rate.js +++ b/lib/rate.js @@ -15,6 +15,7 @@ const rclnodejs = require('../index.js'); const Context = require('./context.js'); const NodeOptions = require('./node_options.js'); +const { OperationError } = require('./errors.js'); const NOP_FN = () => {}; @@ -86,7 +87,11 @@ class Rate { */ async sleep() { if (this.isCanceled()) { - throw new Error('Rate has been cancelled.'); + throw new OperationError('Rate has been cancelled', { + code: 'RATE_CANCELLED', + entityType: 'rate', + details: { frequency: this._hz }, + }); } return new Promise((resolve) => { diff --git a/lib/serialization.js b/lib/serialization.js index 0f676124..3d4cfaec 100644 --- a/lib/serialization.js +++ b/lib/serialization.js @@ -15,6 +15,7 @@ 'use strict'; const rclnodejs = require('./native_loader.js'); +const { TypeValidationError } = require('./errors.js'); class Serialization { /** @@ -25,7 +26,9 @@ class Serialization { */ static serializeMessage(message, typeClass) { if (!(message instanceof typeClass)) { - throw new TypeError('Message must be a valid ros2 message type'); + throw new TypeValidationError('message', message, typeClass.name, { + entityType: 'serializer', + }); } return rclnodejs.serialize( typeClass.type().pkgName, @@ -43,7 +46,9 @@ class Serialization { */ static deserializeMessage(buffer, typeClass) { if (!(buffer instanceof Buffer)) { - throw new TypeError('Buffer is required for deserialization'); + throw new TypeValidationError('buffer', buffer, 'Buffer', { + entityType: 'serializer', + }); } const rosMsg = new typeClass(); rclnodejs.deserialize( diff --git a/lib/time.js b/lib/time.js index a563e837..c7fa80c6 100644 --- a/lib/time.js +++ b/lib/time.js @@ -17,6 +17,7 @@ const rclnodejs = require('./native_loader.js'); const Duration = require('./duration.js'); const ClockType = require('./clock_type.js'); +const { TypeValidationError, RangeValidationError } = require('./errors.js'); const S_TO_NS = 10n ** 9n; /** @@ -36,29 +37,49 @@ class Time { clockType = ClockType.SYSTEM_TIME ) { if (typeof seconds !== 'bigint') { - throw new TypeError('Invalid argument of seconds'); + throw new TypeValidationError('seconds', seconds, 'bigint', { + entityType: 'time', + }); } if (typeof nanoseconds !== 'bigint') { - throw new TypeError('Invalid argument of nanoseconds'); + throw new TypeValidationError('nanoseconds', nanoseconds, 'bigint', { + entityType: 'time', + }); } if (typeof clockType !== 'number') { - throw new TypeError('Invalid argument of clockType'); + throw new TypeValidationError('clockType', clockType, 'number', { + entityType: 'time', + }); } if (seconds < 0n) { - throw new RangeError('seconds value must not be negative'); + throw new RangeValidationError('seconds', seconds, '>= 0', { + entityType: 'time', + }); } if (nanoseconds < 0n) { - throw new RangeError('nanoseconds value must not be negative'); + throw new RangeValidationError('nanoseconds', nanoseconds, '>= 0', { + entityType: 'time', + }); } const total = seconds * S_TO_NS + nanoseconds; if (total >= 2n ** 63n) { - throw new RangeError( - 'Total nanoseconds value is too large to store in C time point.' + throw new RangeValidationError( + 'total nanoseconds', + total, + '< 2^63 (max C time point)', + { + entityType: 'time', + details: { + seconds: seconds, + nanoseconds: nanoseconds, + total: total, + }, + } ); } this._nanoseconds = total; @@ -116,7 +137,9 @@ class Time { this._clockType ); } - throw new TypeError('Invalid argument'); + throw new TypeValidationError('other', other, 'Duration', { + entityType: 'time', + }); } /** @@ -127,7 +150,18 @@ class Time { sub(other) { if (other instanceof Time) { if (other._clockType !== this._clockType) { - throw new TypeError("Can't subtract times with different clock types"); + throw new TypeValidationError( + 'other', + other, + `Time with clock type ${this._clockType}`, + { + entityType: 'time', + details: { + expectedClockType: this._clockType, + providedClockType: other._clockType, + }, + } + ); } return new Duration(0n, this._nanoseconds - other._nanoseconds); } else if (other instanceof Duration) { @@ -137,7 +171,9 @@ class Time { this._clockType ); } - throw new TypeError('Invalid argument'); + throw new TypeValidationError('other', other, 'Time or Duration', { + entityType: 'time', + }); } /** @@ -148,11 +184,24 @@ class Time { eq(other) { if (other instanceof Time) { if (other._clockType !== this._clockType) { - throw new TypeError("Can't compare times with different clock types"); + throw new TypeValidationError( + 'other', + other, + `Time with clock type ${this._clockType}`, + { + entityType: 'time', + details: { + expectedClockType: this._clockType, + providedClockType: other._clockType, + }, + } + ); } return this._nanoseconds === other.nanoseconds; } - throw new TypeError('Invalid argument'); + throw new TypeValidationError('other', other, 'Time', { + entityType: 'time', + }); } /** @@ -163,10 +212,24 @@ class Time { ne(other) { if (other instanceof Time) { if (other._clockType !== this._clockType) { - throw new TypeError("Can't compare times with different clock types"); + throw new TypeValidationError( + 'other', + other, + `Time with clock type ${this._clockType}`, + { + entityType: 'time', + details: { + expectedClockType: this._clockType, + providedClockType: other._clockType, + }, + } + ); } return this._nanoseconds !== other.nanoseconds; } + throw new TypeValidationError('other', other, 'Time', { + entityType: 'time', + }); } /** @@ -177,11 +240,24 @@ class Time { lt(other) { if (other instanceof Time) { if (other._clockType !== this._clockType) { - throw new TypeError("Can't compare times with different clock types"); + throw new TypeValidationError( + 'other', + other, + `Time with clock type ${this._clockType}`, + { + entityType: 'time', + details: { + expectedClockType: this._clockType, + providedClockType: other._clockType, + }, + } + ); } return this._nanoseconds < other.nanoseconds; } - throw new TypeError('Invalid argument'); + throw new TypeValidationError('other', other, 'Time', { + entityType: 'time', + }); } /** @@ -192,11 +268,24 @@ class Time { lte(other) { if (other instanceof Time) { if (other._clockType !== this._clockType) { - throw new TypeError("Can't compare times with different clock types"); + throw new TypeValidationError( + 'other', + other, + `Time with clock type ${this._clockType}`, + { + entityType: 'time', + details: { + expectedClockType: this._clockType, + providedClockType: other._clockType, + }, + } + ); } return this._nanoseconds <= other.nanoseconds; } - throw new TypeError('Invalid argument'); + throw new TypeValidationError('other', other, 'Time', { + entityType: 'time', + }); } /** @@ -207,11 +296,24 @@ class Time { gt(other) { if (other instanceof Time) { if (other._clockType !== this._clockType) { - throw new TypeError("Can't compare times with different clock types"); + throw new TypeValidationError( + 'other', + other, + `Time with clock type ${this._clockType}`, + { + entityType: 'time', + details: { + expectedClockType: this._clockType, + providedClockType: other._clockType, + }, + } + ); } return this._nanoseconds > other.nanoseconds; } - throw new TypeError('Invalid argument'); + throw new TypeValidationError('other', other, 'Time', { + entityType: 'time', + }); } /** @@ -222,11 +324,24 @@ class Time { gte(other) { if (other instanceof Time) { if (other._clockType !== this._clockType) { - throw new TypeError("Can't compare times with different clock types"); + throw new TypeValidationError( + 'other', + other, + `Time with clock type ${this._clockType}`, + { + entityType: 'time', + details: { + expectedClockType: this._clockType, + providedClockType: other._clockType, + }, + } + ); } return this._nanoseconds >= other.nanoseconds; } - throw new TypeError('Invalid argument'); + throw new TypeValidationError('other', other, 'Time', { + entityType: 'time', + }); } /** diff --git a/lib/time_source.js b/lib/time_source.js index 9b22d57b..54ead159 100644 --- a/lib/time_source.js +++ b/lib/time_source.js @@ -19,6 +19,7 @@ const { Clock, ROSClock } = require('./clock.js'); const { ClockType } = Clock; const { Parameter, ParameterType } = require('./parameter.js'); const Time = require('./time.js'); +const { TypeValidationError, OperationError } = require('./errors.js'); const USE_SIM_TIME_PARAM = 'use_sim_time'; const CLOCK_TOPIC = '/clock'; @@ -102,7 +103,9 @@ class TimeSource { */ attachNode(node) { if ((!node) instanceof rclnodejs.ShadowNode) { - throw new TypeError('Invalid argument, must be type of Node'); + throw new TypeValidationError('node', node, 'Node', { + entityType: 'time source', + }); } if (this._node) { @@ -150,8 +153,12 @@ class TimeSource { detachNode() { if (this._clockSubscription) { if (!this._node) { - throw new Error( - 'Unable to destroy previously created clock subscription' + throw new OperationError( + 'Unable to destroy previously created clock subscription', + { + code: 'NO_NODE_ATTACHED', + entityType: 'time source', + } ); } this._node.destroySubscription(this._clockSubscription); @@ -167,7 +174,9 @@ class TimeSource { */ attachClock(clock) { if (!(clock instanceof ROSClock)) { - throw new TypeError('Only clocks with type ROS_TIME can be attached.'); + throw new TypeValidationError('clock', clock, 'ROSClock', { + entityType: 'time source', + }); } clock.rosTimeOverride = this._lastTimeSet; clock.isRosTimeActive = this._isRosTimeActive; diff --git a/lib/utils.js b/lib/utils.js index e851c84d..f4734b65 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -15,6 +15,7 @@ const fs = require('fs'); const fsPromises = require('fs/promises'); const path = require('path'); +const { ValidationError } = require('./errors.js'); /** * Ensure directory exists, create recursively if needed (async) @@ -294,7 +295,12 @@ function compareVersions(version1, version2, operator) { case '!==': return cmp !== 0; default: - throw new Error(`Invalid operator: ${operator}`); + throw new ValidationError(`Invalid operator: ${operator}`, { + code: 'INVALID_OPERATOR', + argumentName: 'operator', + providedValue: operator, + expectedType: "'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte'", + }); } } diff --git a/lib/validator.js b/lib/validator.js index 0d8c5a6b..da41b082 100644 --- a/lib/validator.js +++ b/lib/validator.js @@ -15,16 +15,15 @@ 'use strict'; const rclnodejs = require('./native_loader.js'); +const { TypeValidationError, NameValidationError } = require('./errors.js'); /** * An object - Representing a validator in ROS. * @exports validator */ let validator = { - _createErrorFromValidation: function (result) { - let err = new Error(result[0]); - err.invalidIndex = result[1]; - return err; + _createErrorFromValidation: function (result, nameValue, nameType) { + return new NameValidationError(nameValue, nameType, result[0], result[1]); }, /** @@ -34,14 +33,14 @@ let validator = { */ validateFullTopicName(topic) { if (typeof topic !== 'string') { - throw new TypeError('Invalid argument'); + throw new TypeValidationError('topic', topic, 'string'); } let result = rclnodejs.validateFullTopicName(topic); if (result === null) { return true; } - throw this._createErrorFromValidation(result); + throw this._createErrorFromValidation(result, topic, 'topic'); }, /** @@ -51,14 +50,14 @@ let validator = { */ validateNodeName(name) { if (typeof name !== 'string') { - throw new TypeError('Invalid argument'); + throw new TypeValidationError('name', name, 'string'); } let result = rclnodejs.validateNodeName(name); if (result === null) { return true; } - throw this._createErrorFromValidation(result); + throw this._createErrorFromValidation(result, name, 'node'); }, /** @@ -68,14 +67,14 @@ let validator = { */ validateTopicName(topic) { if (typeof topic !== 'string') { - throw new TypeError('Invalid argument'); + throw new TypeValidationError('topic', topic, 'string'); } let result = rclnodejs.validateTopicName(topic); if (result === null) { return true; } - throw this._createErrorFromValidation(result); + throw this._createErrorFromValidation(result, topic, 'topic'); }, /** @@ -85,14 +84,14 @@ let validator = { */ validateNamespace(namespace) { if (typeof namespace !== 'string') { - throw new TypeError('Invalid argument'); + throw new TypeValidationError('namespace', namespace, 'string'); } let result = rclnodejs.validateNamespace(namespace); if (result === null) { return true; } - throw this._createErrorFromValidation(result); + throw this._createErrorFromValidation(result, namespace, 'namespace'); }, }; diff --git a/test/test-errors.js b/test/test-errors.js new file mode 100644 index 00000000..cc477899 --- /dev/null +++ b/test/test-errors.js @@ -0,0 +1,570 @@ +// Copyright (c) 2025 Mahmoud Alghalayini. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +const assert = require('assert'); +const rclnodejs = require('../index.js'); + +describe('Error handling tests', function () { + describe('RclNodeError', function () { + it('should create basic RclNodeError', function () { + const error = new rclnodejs.RclNodeError('Test error'); + assert.ok(error instanceof Error); + assert.ok(error instanceof rclnodejs.RclNodeError); + assert.strictEqual(error.name, 'RclNodeError'); + assert.strictEqual(error.message, 'Test error'); + assert.strictEqual(error.code, 'UNKNOWN_ERROR'); + assert.ok(error.timestamp instanceof Date); + }); + + it('should create RclNodeError with options', function () { + const error = new rclnodejs.RclNodeError('Test error', { + code: 'TEST_CODE', + nodeName: 'test_node', + entityType: 'publisher', + entityName: 'test_topic', + details: { key: 'value' }, + }); + assert.strictEqual(error.code, 'TEST_CODE'); + assert.strictEqual(error.nodeName, 'test_node'); + assert.strictEqual(error.entityType, 'publisher'); + assert.strictEqual(error.entityName, 'test_topic'); + assert.deepStrictEqual(error.details, { key: 'value' }); + }); + + it('should support error chaining with cause', function () { + const cause = new Error('Original error'); + const error = new rclnodejs.RclNodeError('Wrapped error', { cause }); + assert.strictEqual(error.cause, cause); + }); + + it('should serialize to JSON', function () { + const error = new rclnodejs.RclNodeError('Test error', { + code: 'TEST_CODE', + nodeName: 'test_node', + }); + const json = error.toJSON(); + assert.strictEqual(json.name, 'RclNodeError'); + assert.strictEqual(json.message, 'Test error'); + assert.strictEqual(json.code, 'TEST_CODE'); + assert.strictEqual(json.nodeName, 'test_node'); + assert.ok(json.timestamp); + assert.ok(json.stack); + }); + + it('should have proper toString', function () { + const error = new rclnodejs.RclNodeError('Test error', { + code: 'TEST_CODE', + nodeName: 'test_node', + entityType: 'publisher', + entityName: 'test_topic', + }); + const str = error.toString(); + assert.ok(str.includes('RclNodeError')); + assert.ok(str.includes('Test error')); + assert.ok(str.includes('[TEST_CODE]')); + assert.ok(str.includes('(node: test_node)')); + assert.ok(str.includes('(publisher: test_topic)')); + }); + + it('should maintain stack trace', function () { + const error = new rclnodejs.RclNodeError('Test error'); + assert.ok(error.stack); + assert.ok(error.stack.includes('RclNodeError')); + assert.ok(error.stack.includes('test-errors.js')); + }); + }); + + describe('ValidationError', function () { + it('should create ValidationError', function () { + const error = new rclnodejs.ValidationError('Validation failed'); + assert.ok(error instanceof rclnodejs.RclNodeError); + assert.ok(error instanceof rclnodejs.ValidationError); + assert.strictEqual(error.name, 'ValidationError'); + assert.strictEqual(error.code, 'VALIDATION_ERROR'); + }); + + it('should include validation details', function () { + const error = new rclnodejs.ValidationError('Validation failed', { + argumentName: 'nodeName', + providedValue: 123, + expectedType: 'string', + validationRule: 'type === string', + }); + assert.strictEqual(error.argumentName, 'nodeName'); + assert.strictEqual(error.providedValue, 123); + assert.strictEqual(error.expectedType, 'string'); + assert.strictEqual(error.validationRule, 'type === string'); + }); + }); + + describe('TypeValidationError', function () { + it('should create TypeValidationError', function () { + const error = new rclnodejs.TypeValidationError( + 'nodeName', + 123, + 'string' + ); + assert.ok(error instanceof rclnodejs.ValidationError); + assert.ok(error instanceof rclnodejs.TypeValidationError); + assert.strictEqual(error.name, 'TypeValidationError'); + assert.strictEqual(error.code, 'INVALID_TYPE'); + assert.ok(error.message.includes('nodeName')); + assert.ok(error.message.includes('string')); + assert.ok(error.message.includes('number')); + }); + + it('should include correct properties', function () { + const error = new rclnodejs.TypeValidationError( + 'topicName', + null, + 'string', + { nodeName: 'test_node' } + ); + assert.strictEqual(error.argumentName, 'topicName'); + assert.strictEqual(error.providedValue, null); + assert.strictEqual(error.expectedType, 'string'); + assert.strictEqual(error.nodeName, 'test_node'); + }); + }); + + describe('RangeValidationError', function () { + it('should create RangeValidationError', function () { + const error = new rclnodejs.RangeValidationError( + 'frequency', + 1500, + '0 <= x <= 1000' + ); + assert.ok(error instanceof rclnodejs.ValidationError); + assert.ok(error instanceof rclnodejs.RangeValidationError); + assert.strictEqual(error.name, 'RangeValidationError'); + assert.strictEqual(error.code, 'OUT_OF_RANGE'); + assert.ok(error.message.includes('frequency')); + assert.ok(error.message.includes('1500')); + assert.ok(error.message.includes('0 <= x <= 1000')); + }); + + it('should include validation rule', function () { + const error = new rclnodejs.RangeValidationError( + 'value', + -5, + 'must be positive' + ); + assert.strictEqual(error.argumentName, 'value'); + assert.strictEqual(error.providedValue, -5); + assert.strictEqual(error.validationRule, 'must be positive'); + }); + }); + + describe('NameValidationError', function () { + it('should create NameValidationError', function () { + const error = new rclnodejs.NameValidationError( + 'my/bad/topic', + 'topic', + 'invalid character', + 5 + ); + assert.ok(error instanceof rclnodejs.ValidationError); + assert.ok(error instanceof rclnodejs.NameValidationError); + assert.strictEqual(error.name, 'NameValidationError'); + assert.strictEqual(error.code, 'INVALID_NAME'); + assert.ok(error.message.includes('topic')); + assert.ok(error.message.includes('my/bad/topic')); + assert.ok(error.message.includes('invalid character')); + assert.ok(error.message.includes('at index 5')); + }); + + it('should include validation details', function () { + const error = new rclnodejs.NameValidationError( + 'bad_node', + 'node', + 'contains underscore', + 3 + ); + assert.strictEqual(error.argumentName, 'node'); + assert.strictEqual(error.providedValue, 'bad_node'); + assert.strictEqual(error.invalidIndex, 3); + assert.strictEqual(error.validationResult, 'contains underscore'); + }); + }); + + describe('OperationError', function () { + it('should create OperationError', function () { + const error = new rclnodejs.OperationError('Operation failed'); + assert.ok(error instanceof rclnodejs.RclNodeError); + assert.ok(error instanceof rclnodejs.OperationError); + assert.strictEqual(error.name, 'OperationError'); + assert.strictEqual(error.code, 'OPERATION_ERROR'); + }); + }); + + describe('TimeoutError', function () { + it('should create TimeoutError', function () { + const error = new rclnodejs.TimeoutError('Service request', 5000); + assert.ok(error instanceof rclnodejs.OperationError); + assert.ok(error instanceof rclnodejs.TimeoutError); + assert.strictEqual(error.name, 'TimeoutError'); + assert.strictEqual(error.code, 'TIMEOUT'); + assert.ok(error.message.includes('Service request')); + assert.ok(error.message.includes('5000ms')); + }); + + it('should include timeout properties', function () { + const error = new rclnodejs.TimeoutError('Request', 3000, { + entityName: 'add_two_ints', + }); + assert.strictEqual(error.timeout, 3000); + assert.strictEqual(error.operationType, 'Request'); + assert.strictEqual(error.entityName, 'add_two_ints'); + }); + }); + + describe('AbortError', function () { + it('should create AbortError', function () { + const error = new rclnodejs.AbortError('Service request'); + assert.ok(error instanceof rclnodejs.OperationError); + assert.ok(error instanceof rclnodejs.AbortError); + assert.strictEqual(error.name, 'AbortError'); + assert.strictEqual(error.code, 'ABORTED'); + assert.ok(error.message.includes('Service request')); + assert.ok(error.message.includes('was aborted')); + }); + + it('should create AbortError with reason', function () { + const error = new rclnodejs.AbortError('Request', 'User cancelled'); + assert.ok(error.message.includes('User cancelled')); + assert.strictEqual(error.abortReason, 'User cancelled'); + }); + + it('should include operation type', function () { + const error = new rclnodejs.AbortError('Parameter get'); + assert.strictEqual(error.operationType, 'Parameter get'); + }); + }); + + describe('ServiceNotFoundError', function () { + it('should create ServiceNotFoundError', function () { + const error = new rclnodejs.ServiceNotFoundError('add_two_ints'); + assert.ok(error instanceof rclnodejs.OperationError); + assert.ok(error instanceof rclnodejs.ServiceNotFoundError); + assert.strictEqual(error.name, 'ServiceNotFoundError'); + assert.strictEqual(error.code, 'SERVICE_NOT_FOUND'); + assert.ok(error.message.includes('add_two_ints')); + assert.ok(error.message.includes('not available')); + }); + + it('should include service details', function () { + const error = new rclnodejs.ServiceNotFoundError('my_service', { + nodeName: 'client_node', + }); + assert.strictEqual(error.serviceName, 'my_service'); + assert.strictEqual(error.entityType, 'service'); + assert.strictEqual(error.entityName, 'my_service'); + assert.strictEqual(error.nodeName, 'client_node'); + }); + }); + + describe('NodeNotFoundError', function () { + it('should create NodeNotFoundError', function () { + const error = new rclnodejs.NodeNotFoundError('remote_node'); + assert.ok(error instanceof rclnodejs.OperationError); + assert.ok(error instanceof rclnodejs.NodeNotFoundError); + assert.strictEqual(error.name, 'NodeNotFoundError'); + assert.strictEqual(error.code, 'NODE_NOT_FOUND'); + assert.ok(error.message.includes('remote_node')); + assert.ok(error.message.includes('not found')); + }); + + it('should include node details', function () { + const error = new rclnodejs.NodeNotFoundError('target_node'); + assert.strictEqual(error.targetNodeName, 'target_node'); + assert.strictEqual(error.entityType, 'node'); + assert.strictEqual(error.entityName, 'target_node'); + }); + }); + + describe('ParameterError', function () { + it('should create ParameterError', function () { + const error = new rclnodejs.ParameterError( + 'Parameter operation failed', + 'max_speed' + ); + assert.ok(error instanceof rclnodejs.RclNodeError); + assert.ok(error instanceof rclnodejs.ParameterError); + assert.strictEqual(error.name, 'ParameterError'); + assert.strictEqual(error.code, 'PARAMETER_ERROR'); + assert.strictEqual(error.parameterName, 'max_speed'); + assert.strictEqual(error.entityType, 'parameter'); + assert.strictEqual(error.entityName, 'max_speed'); + }); + }); + + describe('ParameterNotFoundError', function () { + it('should create ParameterNotFoundError', function () { + const error = new rclnodejs.ParameterNotFoundError( + 'max_speed', + 'robot_node' + ); + assert.ok(error instanceof rclnodejs.ParameterError); + assert.ok(error instanceof rclnodejs.ParameterNotFoundError); + assert.strictEqual(error.name, 'ParameterNotFoundError'); + assert.strictEqual(error.code, 'PARAMETER_NOT_FOUND'); + assert.ok(error.message.includes('max_speed')); + assert.ok(error.message.includes('robot_node')); + assert.strictEqual(error.parameterName, 'max_speed'); + assert.strictEqual(error.nodeName, 'robot_node'); + }); + }); + + describe('ParameterTypeError', function () { + it('should create ParameterTypeError', function () { + const error = new rclnodejs.ParameterTypeError( + 'max_speed', + 'PARAMETER_DOUBLE', + 'PARAMETER_INTEGER' + ); + assert.ok(error instanceof rclnodejs.ParameterError); + assert.ok(error instanceof rclnodejs.ParameterTypeError); + assert.strictEqual(error.name, 'ParameterTypeError'); + assert.strictEqual(error.code, 'PARAMETER_TYPE_MISMATCH'); + assert.ok(error.message.includes('max_speed')); + assert.ok(error.message.includes('PARAMETER_DOUBLE')); + assert.ok(error.message.includes('PARAMETER_INTEGER')); + }); + + it('should include type details', function () { + const error = new rclnodejs.ParameterTypeError('param', 'int', 'string'); + assert.strictEqual(error.expectedType, 'int'); + assert.strictEqual(error.actualType, 'string'); + }); + }); + + describe('ReadOnlyParameterError', function () { + it('should create ReadOnlyParameterError', function () { + const error = new rclnodejs.ReadOnlyParameterError('use_sim_time'); + assert.ok(error instanceof rclnodejs.ParameterError); + assert.ok(error instanceof rclnodejs.ReadOnlyParameterError); + assert.strictEqual(error.name, 'ReadOnlyParameterError'); + assert.strictEqual(error.code, 'PARAMETER_READ_ONLY'); + assert.ok(error.message.includes('use_sim_time')); + assert.ok(error.message.includes('read-only')); + assert.strictEqual(error.parameterName, 'use_sim_time'); + }); + }); + + describe('TopicError', function () { + it('should create TopicError', function () { + const error = new rclnodejs.TopicError( + 'Topic operation failed', + '/chatter' + ); + assert.ok(error instanceof rclnodejs.RclNodeError); + assert.ok(error instanceof rclnodejs.TopicError); + assert.strictEqual(error.name, 'TopicError'); + assert.strictEqual(error.code, 'TOPIC_ERROR'); + assert.strictEqual(error.topicName, '/chatter'); + assert.strictEqual(error.entityType, 'topic'); + assert.strictEqual(error.entityName, '/chatter'); + }); + }); + + describe('PublisherError', function () { + it('should create PublisherError', function () { + const error = new rclnodejs.PublisherError( + 'Failed to publish', + '/chatter' + ); + assert.ok(error instanceof rclnodejs.TopicError); + assert.ok(error instanceof rclnodejs.PublisherError); + assert.strictEqual(error.name, 'PublisherError'); + assert.strictEqual(error.code, 'PUBLISHER_ERROR'); + assert.strictEqual(error.entityType, 'publisher'); + }); + }); + + describe('SubscriptionError', function () { + it('should create SubscriptionError', function () { + const error = new rclnodejs.SubscriptionError( + 'Failed to subscribe', + '/chatter' + ); + assert.ok(error instanceof rclnodejs.TopicError); + assert.ok(error instanceof rclnodejs.SubscriptionError); + assert.strictEqual(error.name, 'SubscriptionError'); + assert.strictEqual(error.code, 'SUBSCRIPTION_ERROR'); + assert.strictEqual(error.entityType, 'subscription'); + }); + }); + + describe('ActionError', function () { + it('should create ActionError', function () { + const error = new rclnodejs.ActionError( + 'Action operation failed', + 'fibonacci' + ); + assert.ok(error instanceof rclnodejs.RclNodeError); + assert.ok(error instanceof rclnodejs.ActionError); + assert.strictEqual(error.name, 'ActionError'); + assert.strictEqual(error.code, 'ACTION_ERROR'); + assert.strictEqual(error.actionName, 'fibonacci'); + assert.strictEqual(error.entityType, 'action'); + assert.strictEqual(error.entityName, 'fibonacci'); + }); + }); + + describe('GoalRejectedError', function () { + it('should create GoalRejectedError', function () { + const error = new rclnodejs.GoalRejectedError( + 'fibonacci', + 'goal-123-abc' + ); + assert.ok(error instanceof rclnodejs.ActionError); + assert.ok(error instanceof rclnodejs.GoalRejectedError); + assert.strictEqual(error.name, 'GoalRejectedError'); + assert.strictEqual(error.code, 'GOAL_REJECTED'); + assert.ok(error.message.includes('fibonacci')); + assert.strictEqual(error.goalId, 'goal-123-abc'); + }); + }); + + describe('ActionServerNotFoundError', function () { + it('should create ActionServerNotFoundError', function () { + const error = new rclnodejs.ActionServerNotFoundError('fibonacci'); + assert.ok(error instanceof rclnodejs.ActionError); + assert.ok(error instanceof rclnodejs.ActionServerNotFoundError); + assert.strictEqual(error.name, 'ActionServerNotFoundError'); + assert.strictEqual(error.code, 'ACTION_SERVER_NOT_FOUND'); + assert.ok(error.message.includes('fibonacci')); + assert.ok(error.message.includes('not available')); + }); + }); + + describe('NativeError', function () { + it('should create NativeError', function () { + const error = new rclnodejs.NativeError( + 'rcl_take failed', + 'subscription receive' + ); + assert.ok(error instanceof rclnodejs.RclNodeError); + assert.ok(error instanceof rclnodejs.NativeError); + assert.strictEqual(error.name, 'NativeError'); + assert.strictEqual(error.code, 'NATIVE_ERROR'); + assert.ok(error.message.includes('Native operation failed')); + assert.ok(error.message.includes('subscription receive')); + assert.ok(error.message.includes('rcl_take failed')); + }); + + it('should include native details', function () { + const error = new rclnodejs.NativeError('invalid handle', 'publish', { + entityName: '/chatter', + }); + assert.strictEqual(error.nativeMessage, 'invalid handle'); + assert.strictEqual(error.operation, 'publish'); + assert.strictEqual(error.entityName, '/chatter'); + }); + }); + + describe('Error chaining', function () { + it('should preserve cause chain', function () { + const original = new Error('Original error'); + const wrapped = new rclnodejs.RclNodeError('Wrapped', { + cause: original, + }); + const final = new rclnodejs.TimeoutError('Request', 5000, { + cause: wrapped, + }); + + assert.strictEqual(final.cause, wrapped); + assert.strictEqual(wrapped.cause, original); + }); + + it('should include cause in JSON', function () { + const cause = new Error('Original error'); + const error = new rclnodejs.RclNodeError('Wrapped', { cause }); + const json = error.toJSON(); + assert.ok(json.cause); + assert.strictEqual(json.cause, 'Original error'); + }); + + it('should handle RclNodeError cause in JSON', function () { + const cause = new rclnodejs.ValidationError('Validation failed'); + const error = new rclnodejs.OperationError('Operation failed', { + cause, + }); + const json = error.toJSON(); + assert.ok(json.cause); + assert.strictEqual(json.cause.name, 'ValidationError'); + }); + }); + + describe('Error inheritance', function () { + it('should maintain proper inheritance chain', function () { + const error = new rclnodejs.TypeValidationError('arg', 123, 'string'); + assert.ok(error instanceof Error); + assert.ok(error instanceof rclnodejs.RclNodeError); + assert.ok(error instanceof rclnodejs.ValidationError); + assert.ok(error instanceof rclnodejs.TypeValidationError); + }); + + it('should work with instanceof checks', function () { + const errors = [ + new rclnodejs.TimeoutError('Test', 1000), + new rclnodejs.ParameterNotFoundError('param', 'node'), + new rclnodejs.ServiceNotFoundError('service'), + new rclnodejs.PublisherError('msg', 'topic'), + ]; + + errors.forEach((err) => { + assert.ok(err instanceof Error); + assert.ok(err instanceof rclnodejs.RclNodeError); + }); + }); + }); + + describe('Error catching patterns', function () { + it('should catch specific error types', function () { + function throwTimeout() { + throw new rclnodejs.TimeoutError('Request', 5000); + } + + let caught = false; + try { + throwTimeout(); + } catch (error) { + if (error instanceof rclnodejs.TimeoutError) { + caught = true; + assert.strictEqual(error.timeout, 5000); + } + } + assert.ok(caught); + }); + + it('should catch base error types', function () { + function throwValidationError() { + throw new rclnodejs.TypeValidationError('arg', 123, 'string'); + } + + let caught = false; + try { + throwValidationError(); + } catch (error) { + if (error instanceof rclnodejs.ValidationError) { + caught = true; + } + } + assert.ok(caught); + }); + }); +}); diff --git a/test/test-event-handle.js b/test/test-event-handle.js index 36c8fbaf..2cb2d815 100644 --- a/test/test-event-handle.js +++ b/test/test-event-handle.js @@ -30,13 +30,13 @@ describe('Event handle test suite prior to jazzy', function () { it('Error expected when creating SubscriptionEventCallbacks', function () { assert.throws(() => { new SubscriptionEventCallbacks(); - }, /SubscriptionEventCallbacks is only available in ROS 2 Jazzy and later.$/); + }, /SubscriptionEventCallbacks is only available in ROS 2 Jazzy and later/); }); it('Error expected when creating PublisherEventCallbacks', function () { assert.throws(() => { new PublisherEventCallbacks(); - }, /PublisherEventCallbacks is only available in ROS 2 Jazzy and later.$/); + }, /PublisherEventCallbacks is only available in ROS 2 Jazzy and later/); }); }); diff --git a/test/test-existance.js b/test/test-existance.js index 8e9aad74..70c89f2c 100644 --- a/test/test-existance.js +++ b/test/test-existance.js @@ -415,8 +415,8 @@ describe('rclnodejs class existance testing', function () { () => { qos.avoidRosNameSpaceConventions = 1; }, - TypeError, - 'Invalid argument', + rclnodejs.TypeValidationError, + 'Invalid type', 'Failed to call setter' ); }); @@ -429,8 +429,8 @@ describe('rclnodejs class existance testing', function () { () => { qos.depth = 'abc'; }, - TypeError, - 'Invalid argument', + rclnodejs.TypeValidationError, + 'Invalid type', 'Failed to call setter' ); }); @@ -443,8 +443,8 @@ describe('rclnodejs class existance testing', function () { () => { qos.durability = 'abc'; }, - TypeError, - 'Invalid argument', + rclnodejs.TypeValidationError, + 'Invalid type', 'Failed to call setter' ); }); @@ -457,8 +457,8 @@ describe('rclnodejs class existance testing', function () { () => { qos.history = 'abc'; }, - TypeError, - 'Invalid argument', + rclnodejs.TypeValidationError, + 'Invalid type', 'Failed to call setter' ); }); @@ -471,8 +471,8 @@ describe('rclnodejs class existance testing', function () { () => { qos.reliability = 'abc'; }, - TypeError, - 'Invalid argument', + rclnodejs.TypeValidationError, + 'Invalid type', 'Failed to call setter' ); }); diff --git a/test/test-extra-destroy-methods.js b/test/test-extra-destroy-methods.js index 699b88cc..e73395b7 100644 --- a/test/test-extra-destroy-methods.js +++ b/test/test-extra-destroy-methods.js @@ -39,8 +39,8 @@ describe('Node extra destroy methods testing', function () { function () { node.destroyPublisher('publisher'); }, - TypeError, - 'Invalid argument', + rclnodejs.TypeValidationError, + 'Invalid type', 'Invalid type of parameter' ); @@ -58,8 +58,8 @@ describe('Node extra destroy methods testing', function () { function () { node.destroySubscription('subscription'); }, - TypeError, - 'Invalid argument', + rclnodejs.TypeValidationError, + 'Invalid type', 'Invalid type of parameter' ); @@ -77,8 +77,8 @@ describe('Node extra destroy methods testing', function () { function () { node.destroyClient('client'); }, - TypeError, - 'Invalid argument', + rclnodejs.TypeValidationError, + 'Invalid type', 'Invalid type of parameter' ); @@ -96,8 +96,8 @@ describe('Node extra destroy methods testing', function () { function () { node.destroyService('service'); }, - TypeError, - 'Invalid argument', + rclnodejs.TypeValidationError, + 'Invalid type', 'Invalid type of parameter' ); @@ -114,8 +114,8 @@ describe('Node extra destroy methods testing', function () { function () { node.destroyTimer('timer'); }, - TypeError, - 'Invalid argument', + rclnodejs.TypeValidationError, + 'Invalid type', 'Invalid type of parameter' ); diff --git a/test/test-node-oo.js b/test/test-node-oo.js index 8b3b6982..529fcd71 100644 --- a/test/test-node-oo.js +++ b/test/test-node-oo.js @@ -124,8 +124,8 @@ describe('rclnodejs node test suite', function () { () => { var node = new rclnodejs.Node(param[0], param[1]); }, - TypeError, - 'Invalid argument', + rclnodejs.TypeValidationError, + 'Invalid type', 'The parameters type is invalid!' ); }); diff --git a/test/test-node.js b/test/test-node.js index ade7c8e1..edda7ca5 100644 --- a/test/test-node.js +++ b/test/test-node.js @@ -137,8 +137,8 @@ describe('rclnodejs node test suite', function () { () => { var node = rclnodejs.createNode(param[0], param[1]); }, - TypeError, - 'Invalid argument', + rclnodejs.TypeValidationError, + 'Invalid type', 'The parameters type is invalid!' ); }); diff --git a/test/test-parameter-client.js b/test/test-parameter-client.js index 1e964cb4..0e2f9c34 100644 --- a/test/test-parameter-client.js +++ b/test/test-parameter-client.js @@ -144,16 +144,14 @@ describe('ParameterClient tests', function () { it('should throw error if node is not provided', function () { assert.throws( () => new rclnodejs.ParameterClient(null, 'test_node'), - TypeError, - 'Node is required' + rclnodejs.TypeValidationError ); }); it('should throw error if remote node name is empty', function () { assert.throws( () => new rclnodejs.ParameterClient(clientNode, ''), - TypeError, - 'Remote node name must be a non-empty string' + rclnodejs.TypeValidationError ); }); @@ -275,7 +273,7 @@ describe('ParameterClient tests', function () { await paramClient.getParameters('string_param'); assert.fail('Should have thrown error'); } catch (error) { - assert.ok(error instanceof TypeError); + assert.ok(error instanceof rclnodejs.TypeValidationError); assert.ok(error.message.includes('non-empty array')); } }); @@ -285,7 +283,7 @@ describe('ParameterClient tests', function () { await paramClient.getParameters([]); assert.fail('Should have thrown error'); } catch (error) { - assert.ok(error instanceof TypeError); + assert.ok(error instanceof rclnodejs.TypeValidationError); } }); }); @@ -360,7 +358,7 @@ describe('ParameterClient tests', function () { await paramClient.setParameters({ name: 'test', value: 'value' }); assert.fail('Should have thrown error'); } catch (error) { - assert.ok(error instanceof TypeError); + assert.ok(error instanceof rclnodejs.TypeValidationError); } }); @@ -369,7 +367,7 @@ describe('ParameterClient tests', function () { await paramClient.setParameters([]); assert.fail('Should have thrown error'); } catch (error) { - assert.ok(error instanceof TypeError); + assert.ok(error instanceof rclnodejs.TypeValidationError); } }); }); @@ -430,7 +428,7 @@ describe('ParameterClient tests', function () { await paramClient.describeParameters('string_param'); assert.fail('Should have thrown error'); } catch (error) { - assert.ok(error instanceof TypeError); + assert.ok(error instanceof rclnodejs.TypeValidationError); } }); }); diff --git a/test/test-parameters.js b/test/test-parameters.js index 74667ebb..495d5312 100644 --- a/test/test-parameters.js +++ b/test/test-parameters.js @@ -99,7 +99,10 @@ describe('rclnodejs parameters test suite', function () { param.value = 101n; assert.strictEqual(param.value, 101n); - assertThrowsError(() => (param.value = 'hello world'), TypeError); + assertThrowsError( + () => (param.value = 'hello world'), + rclnodejs.ParameterTypeError + ); }); }); @@ -358,10 +361,16 @@ describe('rclnodejs parameters test suite', function () { assert.ifError(descriptor.validateParameter(param)); param.value = -1n; - assertThrowsError(() => descriptor.validateParameter(param), RangeError); + assertThrowsError( + () => descriptor.validateParameter(param), + rclnodejs.RangeValidationError + ); param.value = 256n; - assertThrowsError(() => descriptor.validateParameter(param), RangeError); + assertThrowsError( + () => descriptor.validateParameter(param), + rclnodejs.RangeValidationError + ); }); it('Integer descriptor with [0-255], step=5 range test', function () { @@ -382,10 +391,16 @@ describe('rclnodejs parameters test suite', function () { assert.ifError(descriptor.validateParameter(param)); param.value = 1n; - assertThrowsError(() => descriptor.validateParameter(param), RangeError); + assertThrowsError( + () => descriptor.validateParameter(param), + rclnodejs.RangeValidationError + ); param.value = 256n; - assertThrowsError(() => descriptor.validateParameter(param), RangeError); + assertThrowsError( + () => descriptor.validateParameter(param), + rclnodejs.RangeValidationError + ); }); }); diff --git a/test/test-rate.js b/test/test-rate.js index 881ff967..41353536 100644 --- a/test/test-rate.js +++ b/test/test-rate.js @@ -52,14 +52,14 @@ describe('rclnodejs rate test suite', function () { await node.createRate(1001); assert.fail(false); } catch (err) { - assert.ok(err instanceof RangeError); + assert.ok(err instanceof rclnodejs.RangeValidationError); } try { await node.createRate(0); assert.fail(false); } catch (err) { - assert.ok(err instanceof RangeError); + assert.ok(err instanceof rclnodejs.RangeValidationError); } }); diff --git a/test/test-security-related.js b/test/test-security-related.js index 539dcde9..a975e0a5 100644 --- a/test/test-security-related.js +++ b/test/test-security-related.js @@ -56,8 +56,8 @@ describe('Destroying non-existent objects testing', function () { () => { node.destroyPublisher(null); }, - TypeError, - 'Invalid argument', + rclnodejs.TypeValidationError, + 'Invalid type', 'Trying to destroy an empty publisher!' ); @@ -75,8 +75,8 @@ describe('Destroying non-existent objects testing', function () { () => { node.destroySubscription(null); }, - TypeError, - 'Invalid argument', + rclnodejs.TypeValidationError, + 'Invalid type', 'Trying to destroy an empty subscription!' ); @@ -94,8 +94,8 @@ describe('Destroying non-existent objects testing', function () { () => { node.destroyClient(null); }, - TypeError, - 'Invalid argument', + rclnodejs.TypeValidationError, + 'Invalid type', 'Trying to destroy an empty client!' ); @@ -113,8 +113,8 @@ describe('Destroying non-existent objects testing', function () { () => { node.destroyService(null); }, - TypeError, - 'Invalid argument', + rclnodejs.TypeValidationError, + 'Invalid type', 'Trying to destroy an empty service!' ); @@ -136,8 +136,8 @@ describe('Destroying non-existent objects testing', function () { () => { node.destroyTimer(null); }, - TypeError, - 'Invalid argument', + rclnodejs.TypeValidationError, + 'Invalid type', 'Trying to destroy an empty timer!' ); @@ -239,8 +239,8 @@ describe('Fuzzing API calls testing', function () { () => { node.createTimer(param[0], param[1]); }, - TypeError, - 'Invalid argument', + rclnodejs.TypeValidationError, + 'Invalid type', 'Failed to createTimer!' ); }); diff --git a/test/test-time-source.js b/test/test-time-source.js index 6c85f388..7a3c96cf 100644 --- a/test/test-time-source.js +++ b/test/test-time-source.js @@ -58,10 +58,10 @@ describe('rclnodejs TimeSource testing', function () { assert.throws(() => { timeSource.attachClock(new Clock()); - }, TypeError); + }, rclnodejs.TypeValidationError); assert.throws(() => { timeSource.attachClock(new Clock(Clock.ClockType.STEADY_TIME)); - }, TypeError); + }, rclnodejs.TypeValidationError); }); it('Test not using sim time', function () { diff --git a/test/test-time.js b/test/test-time.js index e3119e41..9612a4fc 100644 --- a/test/test-time.js +++ b/test/test-time.js @@ -50,23 +50,23 @@ describe('rclnodejs Time/Clock testing', function () { assert.throws(() => { new Time(1n, 1n, 'SYSTEM_TIME'); - }, TypeError); + }, rclnodejs.TypeValidationError); assert.throws(() => { new Time({ seconds: 0n, nanoseconds: 0n }); - }, TypeError); + }, rclnodejs.TypeValidationError); assert.throws(() => { new Time(-1n, 0n); - }, RangeError); + }, rclnodejs.RangeValidationError); assert.throws(() => { new Time(0n, -9007199254740992n); - }, RangeError); + }, rclnodejs.RangeValidationError); assert.throws(() => { new Time(0n, -1n); - }, RangeError); + }, rclnodejs.RangeValidationError); }); it('Construct duration object', function () { @@ -111,27 +111,27 @@ describe('rclnodejs Time/Clock testing', function () { assert.throws(() => { left.eq(right); - }, TypeError); + }, rclnodejs.TypeValidationError); assert.throws(() => { left.ne(right); - }, TypeError); + }, rclnodejs.TypeValidationError); assert.throws(() => { left.lt(right); - }, TypeError); + }, rclnodejs.TypeValidationError); assert.throws(() => { left.lte(right); - }, TypeError); + }, rclnodejs.TypeValidationError); assert.throws(() => { left.gt(right); - }, TypeError); + }, rclnodejs.TypeValidationError); assert.throws(() => { left.gte(right); - }, TypeError); + }, rclnodejs.TypeValidationError); let time = new Time(0n, 1n, ClockType.STEADY_TIME); let duration = new Duration(0n, 1n); @@ -150,7 +150,7 @@ describe('rclnodejs Time/Clock testing', function () { assert.strictEqual(diff.nanoseconds, 1n); assert.throws(() => { time.add(result); - }, TypeError); + }, rclnodejs.TypeValidationError); let nanos = time._nanoseconds; time.secondsAndNanoseconds; @@ -173,27 +173,27 @@ describe('rclnodejs Time/Clock testing', function () { assert.throws(() => { left.eq(5n ** 9n); - }, TypeError); + }, rclnodejs.TypeValidationError); let time = new Time(); assert.throws(() => { left.eq(time); - }, TypeError); + }, rclnodejs.TypeValidationError); assert.throws(() => { left.ne(time); - }, TypeError); + }, rclnodejs.TypeValidationError); assert.throws(() => { left.gt(time); - }, TypeError); + }, rclnodejs.TypeValidationError); assert.throws(() => { left.gte(time); - }, TypeError); + }, rclnodejs.TypeValidationError); assert.throws(() => { left.lt(time); - }, TypeError); + }, rclnodejs.TypeValidationError); assert.throws(() => { left.lte(time); - }, TypeError); + }, rclnodejs.TypeValidationError); }); it('Conversion to Time message', function () { diff --git a/types/base.d.ts b/types/base.d.ts index 3ea7822a..4a2086ea 100644 --- a/types/base.d.ts +++ b/types/base.d.ts @@ -8,6 +8,7 @@ /// /// /// +/// /// /// /// diff --git a/types/errors.d.ts b/types/errors.d.ts new file mode 100644 index 00000000..f0529890 --- /dev/null +++ b/types/errors.d.ts @@ -0,0 +1,447 @@ +// Copyright (c) 2025 Mahmoud Alghalayini. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +declare module 'rclnodejs' { + /** + * Options for RclNodeError constructor + */ + export interface RclNodeErrorOptions { + /** Machine-readable error code (e.g., 'TIMEOUT', 'INVALID_ARGUMENT') */ + code?: string; + /** Name of the node where error occurred */ + nodeName?: string; + /** Type of entity (publisher, subscription, client, etc.) */ + entityType?: string; + /** Name of the entity (topic name, service name, etc.) */ + entityName?: string; + /** Original error that caused this error */ + cause?: Error; + /** Additional error-specific details */ + details?: any; + } + + /** + * Base error class for all rclnodejs errors. + * Provides structured error information with context. + */ + export class RclNodeError extends Error { + /** Error code for machine-readable error identification */ + code: string; + /** Name of the node where error occurred */ + nodeName?: string; + /** Type of entity (publisher, subscription, client, etc.) */ + entityType?: string; + /** Name of the entity (topic name, service name, etc.) */ + entityName?: string; + /** Additional error-specific details */ + details?: any; + /** Original error that caused this error */ + cause?: Error; + /** Timestamp when error was created */ + timestamp: Date; + + /** + * @param message - Human-readable error message + * @param options - Additional error context + */ + constructor(message: string, options?: RclNodeErrorOptions); + + /** + * Returns a detailed error object for logging/serialization + */ + toJSON(): { + name: string; + message: string; + code: string; + nodeName?: string; + entityType?: string; + entityName?: string; + details?: any; + timestamp: string; + stack?: string; + cause?: any; + }; + + /** + * Returns a user-friendly error description + */ + toString(): string; + } + + /** + * Options for ValidationError constructor + */ + export interface ValidationErrorOptions extends RclNodeErrorOptions { + /** Name of the argument that failed validation */ + argumentName?: string; + /** The value that was provided */ + providedValue?: any; + /** The expected type or format */ + expectedType?: string; + /** The validation rule that failed */ + validationRule?: string; + } + + /** + * Error thrown when validation fails + */ + export class ValidationError extends RclNodeError { + /** Name of the argument that failed validation */ + argumentName?: string; + /** The value that was provided */ + providedValue?: any; + /** The expected type or format */ + expectedType?: string; + /** The validation rule that failed */ + validationRule?: string; + + /** + * @param message - Error message + * @param options - Additional options + */ + constructor(message: string, options?: ValidationErrorOptions); + } + + /** + * Type validation error + */ + export class TypeValidationError extends ValidationError { + /** + * @param argumentName - Name of the argument + * @param providedValue - The value that was provided + * @param expectedType - The expected type + * @param options - Additional options + */ + constructor( + argumentName: string, + providedValue: any, + expectedType: string, + options?: RclNodeErrorOptions + ); + } + + /** + * Range/value validation error + */ + export class RangeValidationError extends ValidationError { + /** + * @param argumentName - Name of the argument + * @param providedValue - The value that was provided + * @param constraint - The constraint that was violated + * @param options - Additional options + */ + constructor( + argumentName: string, + providedValue: any, + constraint: string, + options?: RclNodeErrorOptions + ); + } + + /** + * ROS name validation error (topics, nodes, services) + */ + export class NameValidationError extends ValidationError { + /** Index where validation failed */ + invalidIndex: number; + /** The validation error message */ + validationResult: string; + + /** + * @param name - The invalid name + * @param nameType - Type of name (node, topic, service, etc.) + * @param validationResult - The validation error message + * @param invalidIndex - Index where validation failed + * @param options - Additional options + */ + constructor( + name: string, + nameType: string, + validationResult: string, + invalidIndex: number, + options?: RclNodeErrorOptions + ); + } + + /** + * Base class for operation/runtime errors + */ + export class OperationError extends RclNodeError { + /** + * @param message - Error message + * @param options - Additional options + */ + constructor(message: string, options?: RclNodeErrorOptions); + } + + /** + * Request timeout error + */ + export class TimeoutError extends OperationError { + /** Timeout duration in milliseconds */ + timeout: number; + /** Type of operation that timed out */ + operationType: string; + + /** + * @param operationType - Type of operation that timed out + * @param timeoutMs - Timeout duration in milliseconds + * @param options - Additional options + */ + constructor( + operationType: string, + timeoutMs: number, + options?: RclNodeErrorOptions + ); + } + + /** + * Request abortion error + */ + export class AbortError extends OperationError { + /** Type of operation that was aborted */ + operationType: string; + /** Reason for abortion */ + abortReason?: string; + + /** + * @param operationType - Type of operation that was aborted + * @param reason - Reason for abortion + * @param options - Additional options + */ + constructor( + operationType: string, + reason?: string, + options?: RclNodeErrorOptions + ); + } + + /** + * Service not available error + */ + export class ServiceNotFoundError extends OperationError { + /** Name of the service */ + serviceName: string; + + /** + * @param serviceName - Name of the service + * @param options - Additional options + */ + constructor(serviceName: string, options?: RclNodeErrorOptions); + } + + /** + * Remote node not found error + */ + export class NodeNotFoundError extends OperationError { + /** Name of the target node */ + targetNodeName: string; + + /** + * @param nodeName - Name of the node + * @param options - Additional options + */ + constructor(nodeName: string, options?: RclNodeErrorOptions); + } + + /** + * Base error for parameter operations + */ + export class ParameterError extends RclNodeError { + /** Name of the parameter */ + parameterName: string; + + /** + * @param message - Error message + * @param parameterName - Name of the parameter + * @param options - Additional options + */ + constructor( + message: string, + parameterName: string, + options?: RclNodeErrorOptions + ); + } + + /** + * Parameter not found error + */ + export class ParameterNotFoundError extends ParameterError { + /** + * @param parameterName - Name of the parameter + * @param nodeName - Name of the node + * @param options - Additional options + */ + constructor( + parameterName: string, + nodeName: string, + options?: RclNodeErrorOptions + ); + } + + /** + * Parameter type mismatch error + */ + export class ParameterTypeError extends ParameterError { + /** Expected parameter type */ + expectedType: string; + /** Actual parameter type */ + actualType: string; + + /** + * @param parameterName - Name of the parameter + * @param expectedType - Expected parameter type + * @param actualType - Actual parameter type + * @param options - Additional options + */ + constructor( + parameterName: string, + expectedType: string, + actualType: string, + options?: RclNodeErrorOptions + ); + } + + /** + * Read-only parameter modification error + */ + export class ReadOnlyParameterError extends ParameterError { + /** + * @param parameterName - Name of the parameter + * @param options - Additional options + */ + constructor(parameterName: string, options?: RclNodeErrorOptions); + } + + /** + * Base error for topic operations + */ + export class TopicError extends RclNodeError { + /** Name of the topic */ + topicName: string; + + /** + * @param message - Error message + * @param topicName - Name of the topic + * @param options - Additional options + */ + constructor( + message: string, + topicName: string, + options?: RclNodeErrorOptions + ); + } + + /** + * Publisher-specific error + */ + export class PublisherError extends TopicError { + /** + * @param message - Error message + * @param topicName - Name of the topic + * @param options - Additional options + */ + constructor( + message: string, + topicName: string, + options?: RclNodeErrorOptions + ); + } + + /** + * Subscription-specific error + */ + export class SubscriptionError extends TopicError { + /** + * @param message - Error message + * @param topicName - Name of the topic + * @param options - Additional options + */ + constructor( + message: string, + topicName: string, + options?: RclNodeErrorOptions + ); + } + + /** + * Base error for action operations + */ + export class ActionError extends RclNodeError { + /** Name of the action */ + actionName: string; + + /** + * @param message - Error message + * @param actionName - Name of the action + * @param options - Additional options + */ + constructor( + message: string, + actionName: string, + options?: RclNodeErrorOptions + ); + } + + /** + * Goal rejected by action server + */ + export class GoalRejectedError extends ActionError { + /** ID of the rejected goal */ + goalId: string; + + /** + * @param actionName - Name of the action + * @param goalId - ID of the rejected goal + * @param options - Additional options + */ + constructor( + actionName: string, + goalId: string, + options?: RclNodeErrorOptions + ); + } + + /** + * Action server not found + */ + export class ActionServerNotFoundError extends ActionError { + /** + * @param actionName - Name of the action + * @param options - Additional options + */ + constructor(actionName: string, options?: RclNodeErrorOptions); + } + + /** + * Wraps errors from native C++ layer with additional context + */ + export class NativeError extends RclNodeError { + /** Error message from C++ layer */ + nativeMessage: string; + /** Operation that failed */ + operation: string; + + /** + * @param nativeMessage - Error message from C++ layer + * @param operation - Operation that failed + * @param options - Additional options + */ + constructor( + nativeMessage: string, + operation: string, + options?: RclNodeErrorOptions + ); + } +}