@gohcltech/bitbucket-mcp
Version:
Bitbucket integration for Claude via Model Context Protocol
431 lines • 17.6 kB
JavaScript
/**
* @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