UNPKG

@gohcltech/bitbucket-mcp

Version:

Bitbucket integration for Claude via Model Context Protocol

431 lines 17.6 kB
/** * @fileoverview Centralized error handling system for the Bitbucket MCP Server. * * This module provides comprehensive error handling capabilities including: * - Automatic retry logic with exponential backoff * - API error classification and transformation * - Validation error handling with detailed feedback * - Rate limiting error management * - MCP-compatible error response formatting * * The error handler centralizes all error management to ensure consistent * behavior across the entire application and provides robust resilience * for network and API operations. */ import { getLogger } from './logger.js'; import { ValidationError, AuthenticationError, RateLimitError, BitbucketApiError } from './types.js'; /** Default configuration for retry behavior with sensible defaults for API operations */ const DEFAULT_RETRY_CONFIG = { maxRetries: 3, baseDelay: 1000, // 1 second maxDelay: 30000, // 30 seconds backoffFactor: 2, retryableErrors: [ 'ECONNRESET', 'ENOTFOUND', 'ECONNREFUSED', 'ETIMEDOUT', 'NETWORK_ERROR', 'RATE_LIMIT', 'INTERNAL_SERVER_ERROR', ], }; /** * Centralized error handling class with retry logic and error classification. * * This class provides comprehensive error handling capabilities for the Bitbucket MCP server: * - Automatic retry logic with exponential backoff for transient failures * - API error classification and transformation into appropriate error types * - Validation error handling with detailed feedback * - Rate limiting awareness and management * - MCP-compatible error response formatting * * The error handler integrates with the logging system to provide detailed * error tracking and debugging information while maintaining security by * sanitizing sensitive data in error messages. * * @example * ```typescript * const errorHandler = new ErrorHandler({ * maxRetries: 5, * baseDelay: 2000 * }); * * // Retry an operation with automatic error handling * const result = await errorHandler.withRetry( * () => apiCall(), * { operationName: 'fetch_repositories', toolName: 'list_repositories' } * ); * ``` */ export class ErrorHandler { /** Logger instance for error tracking and debugging */ logger = getLogger(); /** Retry configuration with defaults and custom overrides */ retryConfig; /** * Creates a new ErrorHandler instance. * * @param retryConfig - Optional partial configuration to override defaults */ constructor(retryConfig = {}) { this.retryConfig = { ...DEFAULT_RETRY_CONFIG, ...retryConfig }; } /** * Execute a function with retry logic and exponential backoff. * * Automatically retries failed operations using exponential backoff strategy. * Only retries operations that fail with retryable errors (network issues, * rate limits, server errors). Non-retryable errors (authentication, * validation) are thrown immediately. * * @param operation - Async function to execute with retry logic * @param context - Context information for logging and error messages * @param context.operationName - Human-readable name of the operation * @param context.toolName - Optional tool name for categorized logging * @param customRetryConfig - Optional configuration overrides for this operation * @returns Promise that resolves to the operation result * @throws {Error} The final error if all retry attempts are exhausted * * @example * ```typescript * const result = await errorHandler.withRetry( * () => fetch('/api/repos'), * { operationName: 'fetch_repositories', toolName: 'list_repositories' }, * { maxRetries: 5, baseDelay: 2000 } * ); * ``` */ async withRetry(operation, context, customRetryConfig) { const config = { ...this.retryConfig, ...customRetryConfig }; const startTime = Date.now(); let lastError; for (let attempt = 1; attempt <= config.maxRetries + 1; attempt++) { try { if (attempt > 1) { this.logger.debug(`Retry attempt ${attempt - 1}/${config.maxRetries} for ${context.operationName}`, { operation: context.operationName, tool: context.toolName, attempt: attempt - 1, maxRetries: config.maxRetries, }); } return await operation(); } catch (error) { lastError = this.normalizeError(error); const duration = Date.now() - startTime; // Don't retry on the last attempt if (attempt > config.maxRetries) { this.logger.error(`Operation failed after ${config.maxRetries} retries: ${context.operationName}`, lastError, { operation: context.operationName, tool: context.toolName, totalAttempts: attempt - 1, totalDuration: duration, }); throw lastError; } // Check if error is retryable if (!this.isRetryableError(lastError, config)) { this.logger.warn(`Non-retryable error for ${context.operationName}`, { operation: context.operationName, tool: context.toolName, error: lastError.name, message: lastError.message, }); throw lastError; } // Calculate delay with exponential backoff const delay = this.calculateDelay(attempt - 1, config); this.logger.warn(`Retryable error for ${context.operationName}, retrying in ${delay}ms`, { operation: context.operationName, tool: context.toolName, attempt: attempt - 1, maxRetries: config.maxRetries, delay, error: lastError.name, message: lastError.message, }); // Wait before retrying await this.sleep(delay); } } throw lastError; } /** * Handle and classify errors from API responses. * * Transforms raw API errors into appropriate typed errors based on HTTP status codes * and error content. This method ensures consistent error handling across all API * operations and provides meaningful error messages to users. * * @param error - Raw error from HTTP client (axios, fetch, etc.) * @param context - Context information about the failed request * @param context.method - HTTP method that failed * @param context.url - URL that was requested * @param context.toolName - Optional tool name for error categorization * @throws {AuthenticationError} For 401/403 status codes * @throws {RateLimitError} For 429 status codes with retry information * @throws {BitbucketApiError} For other HTTP errors with status codes * @throws {Error} For network errors without status codes * * @example * ```typescript * try { * await apiCall(); * } catch (error) { * errorHandler.handleApiError(error, { * method: 'GET', * url: '/repositories', * toolName: 'list_repositories' * }); * } * ``` */ handleApiError(error, context) { const normalizedError = this.normalizeError(error); // Log the error this.logger.apiError(context.method, context.url, normalizedError, 0); // Throw appropriate error type if (error.response) { const { status, data } = error.response; if (status === 401) { throw new AuthenticationError('Authentication failed. Please verify your token configuration in environment variables (BITBUCKET_API_TOKEN or BITBUCKET_REPOSITORY_ACCESS_TOKEN).'); } if (status === 403) { throw new AuthenticationError('Access forbidden. You may not have permission to access this resource.'); } if (status === 404) { throw new BitbucketApiError('Resource not found', status, data); } if (status === 429) { const retryAfter = this.extractRetryAfter(error.response.headers); throw new RateLimitError('Rate limit exceeded', retryAfter); } if (status >= 500) { throw new BitbucketApiError(`Server error: ${data?.error?.message || 'Internal server error'}`, status, data); } if (status >= 400) { throw new BitbucketApiError(`Client error: ${data?.error?.message || 'Bad request'}`, status, data); } } // Network or other errors if (error.code) { switch (error.code) { case 'ECONNRESET': case 'ECONNREFUSED': case 'ETIMEDOUT': case 'ENOTFOUND': throw new BitbucketApiError(`Network error: ${error.message}`, undefined, { code: error.code }); default: throw new BitbucketApiError(`Request failed: ${error.message}`, undefined, { code: error.code }); } } throw normalizedError; } /** * Handle validation errors with detailed feedback. * * Processes validation errors to provide detailed feedback about what * went wrong and how to fix it. Integrates with the logging system * to track validation issues for debugging. * * @param error - The validation error to handle * @param toolName - Name of the tool where validation failed * @throws {ValidationError} Always throws a ValidationError with enhanced context */ handleValidationError(error, toolName) { this.logger.error(`Validation error in ${toolName}`, error, { tool: toolName, validation: error.details, }); throw new ValidationError(`${toolName}: ${error.message}`, error.details); } /** * Create error response for MCP tools. * * Formats errors into the standard MCP response format expected by Claude. * Ensures consistent error presentation and includes appropriate context * while maintaining security by not exposing sensitive information. * * @param error - The error to format * @param toolName - Name of the tool that encountered the error * @returns MCP-compatible error response object */ createErrorResponse(error, toolName) { const errorMessage = this.formatErrorMessage(error, toolName); this.logger.toolError(toolName, error, 0); return { isError: true, content: [ { type: 'text', text: errorMessage, }, ], }; } /** * Normalizes various error types into standard Error objects. * * Handles different error formats that might be thrown by various libraries * and APIs, ensuring consistent error handling throughout the application. * * @private * @param error - Error of any type to normalize * @returns Normalized Error object */ normalizeError(error) { if (error instanceof Error) { return error; } if (typeof error === 'string') { return new Error(error); } if (error && typeof error === 'object') { const message = error.message || error.error || JSON.stringify(error); return new Error(message); } return new Error('Unknown error occurred'); } /** * Determines if an error should trigger a retry attempt. * * Evaluates errors against retry criteria to decide whether the operation * should be retried. Authentication and validation errors are never retried, * while network errors and server errors are typically retryable. * * @private * @param error - Error to evaluate for retry eligibility * @param config - Retry configuration with retryable error patterns * @returns True if the error should trigger a retry attempt */ isRetryableError(error, config) { // Check if error type is retryable if (error instanceof RateLimitError) { return true; } if (error instanceof AuthenticationError) { return false; // Don't retry auth errors } if (error instanceof ValidationError) { return false; // Don't retry validation errors } if (error instanceof BitbucketApiError) { // Retry on 5xx server errors return error.statusCode ? error.statusCode >= 500 : false; } // Check error message/code against retryable patterns const errorString = error.message.toLowerCase(); return config.retryableErrors.some(retryableError => errorString.includes(retryableError.toLowerCase()) || error.code === retryableError); } /** * Calculates the delay before the next retry attempt using exponential backoff. * * Implements exponential backoff with a maximum delay cap to prevent * excessive wait times while providing increasing delays for subsequent retries. * * @private * @param attempt - Current attempt number (0-based) * @param config - Retry configuration with delay parameters * @returns Delay in milliseconds before the next retry */ calculateDelay(attempt, config) { const delay = config.baseDelay * Math.pow(config.backoffFactor, attempt); return Math.min(delay, config.maxDelay); } /** * Creates a promise that resolves after the specified delay. * * @private * @param ms - Delay in milliseconds * @returns Promise that resolves after the delay */ sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Extracts retry-after information from HTTP response headers. * * Parses rate limit headers to determine when the next request can be made. * Supports both standard 'Retry-After' and Bitbucket-specific headers. * * @private * @param headers - HTTP response headers * @returns Retry delay in milliseconds, or undefined if not specified */ extractRetryAfter(headers) { const retryAfter = headers['retry-after'] || headers['x-ratelimit-reset']; if (retryAfter) { const seconds = parseInt(retryAfter, 10); return isNaN(seconds) ? undefined : seconds * 1000; // Convert to milliseconds } return undefined; } /** * Formats error messages for consistent presentation to users. * * Creates user-friendly error messages based on error type while maintaining * security by not exposing sensitive information. Provides context about * the operation that failed and any relevant recovery information. * * @private * @param error - Error to format * @param toolName - Name of the tool where the error occurred * @returns Formatted error message string */ formatErrorMessage(error, toolName) { if (error instanceof ValidationError) { return `Validation error in ${toolName}: ${error.message}`; } if (error instanceof AuthenticationError) { return `Authentication error in ${toolName}: ${error.message}`; } if (error instanceof RateLimitError) { const retryMessage = error.retryAfter ? ` Please try again in ${Math.ceil(error.retryAfter / 1000)} seconds.` : ' Please try again later.'; return `Rate limit error in ${toolName}: ${error.message}${retryMessage}`; } if (error instanceof BitbucketApiError) { let message = `Bitbucket API error in ${toolName}: ${error.message}`; if (error.statusCode) { message += ` (HTTP ${error.statusCode})`; } return message; } return `Error in ${toolName}: ${error.message}`; } } /** Global singleton instance of the error handler */ let globalErrorHandler; /** * Creates and configures the global error handler instance. * * Initializes the singleton error handler with the provided configuration. * This should be called once during application startup. * * @param config - Optional configuration overrides for retry behavior * @returns The configured ErrorHandler instance */ export function createErrorHandler(config) { globalErrorHandler = new ErrorHandler(config); return globalErrorHandler; } /** * Gets the global error handler instance. * * Returns the singleton error handler, creating a default instance if none * has been explicitly created. For production use, prefer calling * createErrorHandler() first to ensure proper configuration. * * @returns The global ErrorHandler instance */ export function getErrorHandler() { if (!globalErrorHandler) { globalErrorHandler = new ErrorHandler(); } return globalErrorHandler; } //# sourceMappingURL=error-handler.js.map