Enhanced Error Preservation
kkrpc provides enhanced error preservation that maintains complete error information when exceptions are thrown across RPC boundaries. This includes error names, messages, stack traces, causes, and custom properties.
Features
- Complete Error Preservation: Name, message, stack trace, and custom properties
- Error Causes: Support for modern Error API with
{ cause }
option - Custom Error Classes: Maintains inheritance and custom properties
- Stack Traces: Preserves original stack traces for debugging
- Custom Properties: Any additional properties on error objects
- Nested Errors: Support for error chains and complex error structures
How It Works
Error preservation is implemented through enhanced serialization and deserialization:
- Error Serialization:
serializeError()
converts Error objects toEnhancedError
interface - Property Extraction: Iterates over all enumerable properties on the error object
- Transmission: Sends the serialized error data over RPC
- Deserialization:
deserializeError()
reconstructs Error objects with all properties - Type Preservation: Maintains error names and custom properties
Basic Usage
Simple Error Handling
// API that throws errorsexport const apiImplementation = { async divide(a: number, b: number) { if (b === 0) { throw new Error("Division by zero") } return a / b }}
// Client side error handlingtry { const result = await api.divide(10, 0)} catch (error) { console.log(error.name) // "Error" console.log(error.message) // "Division by zero" console.log(error.stack) // Full stack trace preserved}
Custom Error Classes
// Define custom error classesclass ValidationError extends Error { constructor(message: string, public field: string, public code: number) { super(message) this.name = 'ValidationError' }}
class DatabaseError extends Error { constructor( message: string, public query: string, public code: number, public retryable: boolean = false ) { super(message) this.name = 'DatabaseError' }}
// API implementation with custom errorsexport const apiImplementation = { async createUser(userData: any) { if (!userData.email) { throw new ValidationError("Email is required", "email", 400) }
try { // Database operation const query = "INSERT INTO users (email) VALUES (?)" // ... database logic } catch (dbError) { throw new DatabaseError( "Failed to create user", query, 500, true // retryable ) } }}
// Client side handlingtry { await api.createUser({ name: "John" }) // Missing email} catch (error) { if (error.name === 'ValidationError') { console.log(`Validation failed for field: ${error.field}`) console.log(`Error code: ${error.code}`) } else if (error.name === 'DatabaseError') { console.log(`Database error: ${error.message}`) console.log(`Failed query: ${error.query}`) if (error.retryable) { console.log("Operation can be retried") } }}
Advanced Features
Error Causes (Modern Error API)
// API with error causesexport const apiImplementation = { async processPayment(amount: number) { try { await this.validatePayment(amount) } catch (validationError) { // Chain errors with cause throw new Error("Payment processing failed", { cause: validationError }) } },
async validatePayment(amount: number) { if (amount <= 0) { throw new Error("Amount must be positive") } // ... more validation }}
// Client sidetry { await api.processPayment(-100)} catch (error) { console.log(error.message) // "Payment processing failed" console.log(error.cause.message) // "Amount must be positive"
// Walk the error chain let currentError = error while (currentError.cause) { console.log(`Caused by: ${currentError.cause.message}`) currentError = currentError.cause }}
Custom Error Properties
// Network error with detailed informationclass NetworkError extends Error { constructor( message: string, public url: string, public statusCode: number, public method: string ) { super(message) this.name = 'NetworkError' }}
// API with detailed error contextexport const apiImplementation = { async fetchUserData(userId: string) { const error = new NetworkError( "Failed to fetch user data", `https://api.example.com/users/${userId}`, 404, "GET" )
// Add custom properties error.timestamp = new Date().toISOString() error.requestId = generateRequestId() error.userId = userId error.retryCount = 3 error.headers = { 'Content-Type': 'application/json', 'Authorization': 'Bearer ***' }
throw error }}
// Client sidetry { await api.fetchUserData("invalid-id")} catch (error) { console.log(error.name) // "NetworkError" console.log(error.url) // "https://api.example.com/users/invalid-id" console.log(error.statusCode) // 404 console.log(error.method) // "GET" console.log(error.timestamp) // ISO timestamp console.log(error.requestId) // Request ID console.log(error.userId) // "invalid-id" console.log(error.retryCount) // 3 console.log(error.headers) // Headers object}
Complex Error Structures
// Multi-level error with nested informationexport const apiImplementation = { async processBatch(items: any[]) { const errors = []
for (let i = 0; i < items.length; i++) { try { await this.processItem(items[i]) } catch (itemError) { errors.push({ index: i, item: items[i], error: itemError }) } }
if (errors.length > 0) { const batchError = new Error(`Failed to process ${errors.length} items`) batchError.name = 'BatchProcessingError' batchError.failedItems = errors batchError.totalItems = items.length batchError.successCount = items.length - errors.length batchError.failureRate = errors.length / items.length
throw batchError } }}
// Client sidetry { await api.processBatch(items)} catch (error) { if (error.name === 'BatchProcessingError') { console.log(`${error.successCount}/${error.totalItems} items processed successfully`) console.log(`Failure rate: ${(error.failureRate * 100).toFixed(1)}%`)
error.failedItems.forEach(({ index, item, error: itemError }) => { console.log(`Item ${index} failed:`, itemError.message) }) }}
Error Serialization Details
EnhancedError Interface
interface EnhancedError { name: string message: string stack?: string cause?: any [key: string]: any // Custom properties}
Serialization Process
// Example of what happens internallyfunction serializeError(error: Error): EnhancedError { const enhanced: EnhancedError = { name: error.name, message: error.message }
// Include stack trace if (error.stack) { enhanced.stack = error.stack }
// Include cause (modern Error API) if ('cause' in error && error.cause !== undefined) { enhanced.cause = error.cause }
// Include custom properties for (const key in error) { if (key !== 'name' && key !== 'message' && key !== 'stack' && key !== 'cause') { enhanced[key] = (error as any)[key] } }
return enhanced}
Best Practices
1. Use Specific Error Types
// Good: Specific error typesclass ValidationError extends Error { /* ... */ }class AuthenticationError extends Error { /* ... */ }class DatabaseError extends Error { /* ... */ }
// Less ideal: Generic errorsthrow new Error("Something went wrong")
2. Include Useful Context
// Good: Rich error contextconst error = new DatabaseError("Query failed", query, 500)error.timestamp = Date.now()error.connectionId = conn.iderror.retryAttempt = retryCount
// Less ideal: Minimal contextthrow new Error("Database error")
3. Handle Error Types Appropriately
try { await api.someOperation()} catch (error) { switch (error.name) { case 'ValidationError': // Show user-friendly validation messages showValidationErrors(error.field, error.message) break
case 'AuthenticationError': // Redirect to login redirectToLogin() break
case 'NetworkError': // Show retry option if (error.retryable) { showRetryButton() } break
default: // Log unexpected errors console.error('Unexpected error:', error) showGenericErrorMessage() }}
4. Error Logging and Monitoring
// Centralized error handlingfunction handleRPCError(error: any, context: string) { // Log error with full context logger.error({ message: error.message, name: error.name, stack: error.stack, cause: error.cause, context, timestamp: new Date().toISOString(), ...error // Include all custom properties })
// Report to monitoring service errorReporter.report(error, { context })}
// Usagetry { await api.criticalOperation()} catch (error) { handleRPCError(error, 'critical-operation') throw error // Re-throw if needed}
Limitations
- Circular References: Handled gracefully but may lose some nested data
- Function Properties: Functions on error objects cannot be serialized
- Prototype Chain: Only enumerable properties are preserved
- Large Objects: Very large error objects may impact performance
Error Testing
// Test error preservationdescribe('Error Preservation', () => { test('preserves custom error properties', async () => { try { await api.throwCustomError() fail('Should have thrown an error') } catch (error) { expect(error.name).toBe('CustomError') expect(error.code).toBe(404) expect(error.details).toEqual({ field: 'userId' }) expect(error.stack).toBeDefined() } })
test('preserves error causes', async () => { try { await api.throwErrorWithCause() } catch (error) { expect(error.message).toBe('Operation failed') expect(error.cause).toBeDefined() expect(error.cause.message).toBe('Root cause') } })})
Enhanced error preservation in kkrpc ensures that debugging and error handling remain effective across distributed systems, maintaining the same level of detail as local error handling.