mcp-ynab
Version:
Model Context Protocol server for YNAB integration
199 lines (172 loc) • 5.47 kB
JavaScript
import logger from './logger.js';
class ErrorHandler {
constructor() {
this.retryDelays = [1000, 2000, 4000, 8000]; // Exponential backoff in milliseconds
}
/**
* Handle YNAB API errors with appropriate retries and user-friendly messages
*/
async handleApiError(error, operation = 'API call', attempt = 1, maxAttempts = 3) {
// Parse YNAB-specific errors
const ynabError = this.parseYnabError(error);
// Determine if we should retry
if (this.shouldRetry(ynabError, attempt, maxAttempts)) {
const delay = this.retryDelays[Math.min(attempt - 1, this.retryDelays.length - 1)];
await logger.error(`${operation} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`, {
operation,
attempt,
maxAttempts,
delay,
error: ynabError.message
});
await this.delay(delay);
throw new RetryableError(ynabError.message, attempt);
}
// No more retries, throw final error
throw new YnabError(ynabError.message, ynabError.code, ynabError.type);
}
/**
* Parse YNAB API error into structured format
*/
parseYnabError(error) {
let message = 'Unknown error occurred';
let code = 'UNKNOWN_ERROR';
let type = 'general';
if (error.error) {
// YNAB SDK error format
const ynabError = error.error;
if (ynabError.detail) {
message = ynabError.detail;
} else if (ynabError.name) {
message = ynabError.name;
}
// Handle specific YNAB error types
if (error.status === 401) {
code = 'AUTHENTICATION_ERROR';
type = 'auth';
message = 'Invalid or expired YNAB API key. Please check your YNAB_API_KEY environment variable.';
} else if (error.status === 403) {
code = 'AUTHORIZATION_ERROR';
type = 'auth';
message = 'Access denied. Your API key may not have permission to access this resource.';
} else if (error.status === 404) {
code = 'NOT_FOUND';
type = 'resource';
message = 'The requested budget or resource was not found.';
} else if (error.status === 429) {
code = 'RATE_LIMITED';
type = 'throttle';
message = 'YNAB API rate limit exceeded. Please wait before making more requests.';
} else if (error.status >= 500) {
code = 'SERVER_ERROR';
type = 'server';
message = 'YNAB API server error. Please try again later.';
}
} else if (error.message) {
// Generic error
message = error.message;
// Try to categorize generic errors
if (message.includes('YNAB_API_KEY')) {
code = 'MISSING_API_KEY';
type = 'config';
} else if (message.includes('network') || message.includes('timeout')) {
code = 'NETWORK_ERROR';
type = 'network';
}
}
return { message, code, type };
}
/**
* Determine if an error should be retried
*/
shouldRetry(errorInfo, attempt, maxAttempts) {
if (attempt >= maxAttempts) {
return false;
}
// Don't retry authentication or configuration errors
if (errorInfo.type === 'auth' || errorInfo.type === 'config') {
return false;
}
// Don't retry 404 errors
if (errorInfo.code === 'NOT_FOUND') {
return false;
}
// Retry network, server, and throttling errors
return ['network', 'server', 'throttle'].includes(errorInfo.type);
}
/**
* Create a delay (for retry logic)
*/
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Wrap an async function with error handling and retry logic
*/
async withRetry(asyncFn, operation = 'operation', maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await asyncFn();
} catch (error) {
if (error instanceof RetryableError) {
// This error came from handleApiError, continue retrying
continue;
}
// Handle the error and determine if we should retry
await this.handleApiError(error, operation, attempt, maxAttempts);
}
}
}
/**
* Format error for MCP response
*/
formatForMCP(error) {
if (error instanceof YnabError) {
return {
error: 'tool_error',
message: error.message,
code: error.code,
type: error.type
};
}
return {
error: 'tool_error',
message: error.message || 'An unexpected error occurred',
code: 'UNKNOWN_ERROR',
type: 'general'
};
}
/**
* Validate required environment variables
*/
validateEnvironment() {
const required = ['YNAB_API_KEY'];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
throw new YnabError(
`Missing required environment variables: ${missing.join(', ')}. Please check your .env file.`,
'MISSING_ENV_VARS',
'config'
);
}
}
}
/**
* Custom error classes
*/
class YnabError extends Error {
constructor(message, code = 'UNKNOWN_ERROR', type = 'general') {
super(message);
this.name = 'YnabError';
this.code = code;
this.type = type;
}
}
class RetryableError extends Error {
constructor(message, attempt) {
super(message);
this.name = 'RetryableError';
this.attempt = attempt;
}
}
export { ErrorHandler, YnabError, RetryableError };