/** * Error Mapper - Pattern-based error translation engine * * Converts protocol-specific errors into structured CoachingError instances * using declarative error patterns. Provides a cleaner alternative to scattered * if-else error handling logic throughout adapter code. * * ## Key Features * * - **Declarative patterns**: Define error scenarios upfront, not scattered in code * - **Three matching strategies**: String, RegExp, or custom function * - **Template interpolation**: Insert error properties into messages with {field} syntax * - **Context extraction**: Pull relevant debugging info from error objects * - **Fallback handling**: Graceful defaults when no pattern matches * * ## Design Benefits * * 1. **Visibility**: All error scenarios visible at a glance * 2. **Maintainability**: Add new patterns without touching control flow * 3. **Consistency**: All errors transformed through same pipeline * 4. **Testability**: Easy to test patterns in isolation * * ## Usage Pattern * * 1. Create mapper with defaults * 2. Add patterns for known errors * 3. Use in try-catch blocks to translate errors * * @example Basic usage * ```typescript * const mapper = new ErrorMapper( * [ * { * match: "ECONNREFUSED", * code: "CONNECTION_FAILED", * hint: "Server is not reachable", * fix: "Check server is running" * } * ], * { * defaultCode: "UNKNOWN_ERROR", * defaultHint: "An unexpected error occurred" * } * ); * * try { * await connect(); * } catch (error) { * throw mapper.map(error, { operation: "connect" }); * } * ``` * * @example Regex matching with template * ```typescript * mapper.addPattern({ * match: /timeout|timed out/i, * code: "OPERATION_TIMEOUT", * messageTemplate: "Operation timed out: {message}", * hint: "The operation took too long", * fix: "Increase timeout in configuration" * }); * ``` * * @example Custom predicate with context extraction * ```typescript * mapper.addPattern({ * match: (error) => { * return error.code === "ENOTFOUND" || error.code === "ECONNREFUSED"; * }, * code: "ADAPTER_CONNECTION_FAILED", * hint: "Network connection failed", * fix: "Check network connectivity and server address", * extractContext: (error) => ({ * code: (error as any).code, * syscall: (error as any).syscall, * hostname: (error as any).hostname * }) * }); * ``` * * @module @wavespec/kit/error */ import type { ErrorPattern, ErrorMapperConfig, ErrorContext } from './types.js'; import { CoachingError } from '@wavespec/types'; /** * ErrorMapper - Pattern-based error translation * * Translates protocol-specific errors to CoachingErrors using declarative patterns. * Patterns are matched in order (first match wins), so arrange specific patterns * before generic ones. * * The mapper follows a simple pipeline: * 1. Normalize the error to an Error object * 2. Try each pattern in order until one matches * 3. Build CoachingError from the matched pattern * 4. Fall back to default error if no pattern matches * * @example Simple string matching * ```typescript * const mapper = new ErrorMapper([], { * defaultCode: "UNKNOWN", * defaultHint: "Unexpected error" * }); * * mapper.addPattern({ * match: "ECONNREFUSED", * code: "CONNECTION_FAILED", * hint: "Server refused connection", * fix: "Verify server is running" * }); * * const coachingError = mapper.map( * new Error("connect ECONNREFUSED 127.0.0.1:8080"), * { operation: "connect", port: 8080 } * ); * ``` * * @example Regex matching with severity * ```typescript * mapper.addPattern({ * match: /deprecated|deprecation/i, * code: "DEPRECATED_FEATURE", * hint: "You're using a deprecated feature", * fix: "Migrate to the recommended alternative", * severity: "warning" * }); * ``` */ export class ErrorMapper { private readonly patterns: ErrorPattern[] = []; private readonly config: ErrorMapperConfig; /** * Create a new ErrorMapper * * @param patterns - Initial error patterns (optional, can add more later) * @param config - Mapper configuration with defaults for unmatched errors * * @example With initial patterns * ```typescript * const mapper = new ErrorMapper( * [ * { match: "ECONNREFUSED", code: "CONNECTION_FAILED", hint: "..." } * ], * { defaultCode: "UNKNOWN", defaultHint: "..." } * ); * ``` * * @example Empty mapper (add patterns later) * ```typescript * const mapper = new ErrorMapper([], { defaultCode: "UNKNOWN", defaultHint: "..." }); * mapper.addPattern({ ... }); * ``` */ constructor(patterns: ErrorPattern[], config?: ErrorMapperConfig) { this.patterns = [...patterns]; this.config = config ?? { defaultCode: 'UNKNOWN_ERROR', defaultHint: 'An unexpected error occurred', }; } /** * Add a single error pattern * * Patterns are matched in the order they're added. Add more specific * patterns first, then generic patterns as fallbacks. * * @param pattern - Error pattern to add * @returns this (for method chaining) * * @example Chaining patterns * ```typescript * mapper * .addPattern({ match: "ECONNREFUSED", code: "CONNECTION_REFUSED", ... }) * .addPattern({ match: "ENOTFOUND", code: "HOST_NOT_FOUND", ... }) * .addPattern({ match: /timeout/i, code: "TIMEOUT", ... }); * ``` */ addPattern(pattern: ErrorPattern): this { this.patterns.push(pattern); return this; } /** * Add multiple error patterns at once * * Convenience method for bulk pattern registration. * Patterns are added in array order. * * @param patterns - Array of error patterns to add * @returns this (for method chaining) * * @example Bulk pattern registration * ```typescript * const networkPatterns: ErrorPattern[] = [ * { match: "ECONNREFUSED", code: "CONNECTION_REFUSED", ... }, * { match: "ENOTFOUND", code: "HOST_NOT_FOUND", ... }, * { match: "ETIMEDOUT", code: "TIMEOUT", ... } * ]; * * mapper.addPatterns(networkPatterns); * ``` */ addPatterns(patterns: ErrorPattern[]): this { this.patterns.push(...patterns); return this; } /** * Map an error to CoachingError using patterns * * This is the main entry point for error translation. It: * 1. Normalizes the error to an Error object * 2. Tries each pattern in order until one matches * 3. Builds a CoachingError from the matched pattern * 4. Falls back to default error if no pattern matches * * The returned CoachingError includes: * - Structured error code for programmatic handling * - Human-readable message (optionally interpolated) * - Hint explaining what went wrong * - Fix suggestion for how to resolve it * - Context combining provided context + extracted context * - Recovery strategy (if pattern defines one) * * @param error - The error to translate (Error, string, or unknown) * @param context - Additional context about the error (operation, entity names, etc.) * @returns CoachingError with actionable guidance * * @example Basic translation * ```typescript * try { * await client.connect(); * } catch (error) { * throw mapper.map(error, { operation: "connect" }); * } * ``` * * @example With entity context * ```typescript * try { * await client.callTool("weather", args); * } catch (error) { * throw mapper.map(error, { * operation: "call_tool", * toolName: "weather" * }); * } * ``` */ map(error: unknown, context?: ErrorContext): CoachingError { const err = this.normalizeError(error); // Try each pattern in order (first match wins) for (const pattern of this.patterns) { if (this.matchesPattern(err, pattern)) { return this.buildError(err, pattern, context); } } // No pattern matched - use defaults return this.buildDefaultError(err, context); } /** * Check if an error matches a pattern * * Supports three matching strategies: * 1. String: Case-insensitive substring match in error.message * 2. RegExp: Pattern match in error.message * 3. Function: Custom predicate (error: Error) => boolean * * @param error - The error to check * @param pattern - The pattern to match against * @returns true if the error matches the pattern */ private matchesPattern(error: Error, pattern: ErrorPattern): boolean { const matcher = pattern.match; // Function matcher - maximum flexibility if (typeof matcher === 'function') { try { return matcher(error); } catch { // If the matcher throws, treat as non-match return false; } } // RegExp matcher - pattern matching if (matcher instanceof RegExp) { return matcher.test(error.message); } // String matcher - case-insensitive substring match return error.message.toLowerCase().includes(matcher.toLowerCase()); } /** * Build CoachingError from a matched pattern * * Constructs the final CoachingError by: * 1. Using pattern's messageTemplate (with interpolation) or original error.message * 2. Extracting context from error if pattern defines extractContext * 3. Merging provided context + extracted context + original error info * 4. Including all pattern metadata (code, hint, fix, severity, etc.) * * @param error - The original error * @param pattern - The matched pattern * @param context - Provided context from caller * @returns CoachingError with complete error information */ private buildError( error: Error, pattern: ErrorPattern, context?: ErrorContext ): CoachingError { // Use template with interpolation, or fall back to original message const message = pattern.messageTemplate ? this.interpolate(pattern.messageTemplate, error) : error.message; // Extract pattern-specific context const extractedContext = pattern.extractContext?.(error) ?? {}; // Combine all context sources const combinedContext = { originalError: error.message, errorName: error.name, ...context, ...extractedContext, }; return new CoachingError({ code: pattern.code, message, hint: pattern.hint, fix: pattern.fix, severity: pattern.severity, learnMore: pattern.learnMore, context: combinedContext, recovery: pattern.recovery, }); } /** * Build default error when no pattern matches * * Falls back to configured defaults when the error doesn't match * any pattern. This ensures we always return a structured error, * even for unexpected error scenarios. * * @param error - The original error * @param context - Provided context from caller * @returns CoachingError with default values */ private buildDefaultError( error: Error, context?: ErrorContext ): CoachingError { return new CoachingError({ code: this.config.defaultCode, message: error.message || 'Unknown error occurred', hint: this.config.defaultHint, fix: this.config.defaultFix, context: { originalError: error.message, errorName: error.name, ...context, }, }); } /** * Interpolate template string with error properties * * Replaces {field} placeholders with values from the error object. * Common fields: * - {message}: error.message * - {name}: error.name * - {code}: error.code (for network errors) * - {syscall}: error.syscall * - Any custom property on the error object * * If a field doesn't exist, the placeholder is left as-is: {field} * * @param template - Template string with {field} placeholders * @param error - Error object with properties to interpolate * @returns Interpolated string * * @example * ```typescript * interpolate("Connection failed: {message}", error) * // => "Connection failed: ECONNREFUSED" * ``` */ private interpolate(template: string, error: Error): string { return template.replaceAll(/\{(\w+)\}/g, (_, key) => { const value = (error as any)[key]; return value === undefined ? `{${key}}` : String(value); }); } /** * Normalize unknown error to Error object * * Handles various error formats: * - Error instances: Use as-is * - Strings: Wrap in Error * - Objects with message: Convert to Error * - Other: Convert to string, then wrap in Error * * This ensures we always have a consistent Error object to work with, * regardless of what was thrown. * * @param error - Error in any format * @returns Normalized Error object */ private normalizeError(error: unknown): Error { // Already an Error - use it if (error instanceof Error) { return error; } // String - wrap in Error if (typeof error === 'string') { return new Error(error); } // Object with message - convert to Error if (error && typeof error === 'object' && 'message' in error) { const err = new Error(String(error.message)); if ('name' in error) { err.name = String(error.name); } // Preserve any additional properties Object.assign(err, error); return err; } // Everything else - stringify and wrap return new Error(String(error)); } }