UNPKG

survey-mcp-server

Version:

Survey management server handling survey creation, response collection, analysis, and reporting with database access for data management

409 lines 16.7 kB
import axios from 'axios'; import { BaseService } from './base.js'; import { ExternalApiError, ValidationError } from '../middleware/error-handling.js'; import { securityManager } from '../security/index.js'; import { logger } from '../utils/logger.js'; import { config } from '../utils/config.js'; export class ExternalApiService extends BaseService { constructor(config) { super('ExternalApiService', config?.circuitBreaker, config?.retry); this.endpoints = new Map(); this.clients = new Map(); this.rateLimitStates = new Map(); this.requestCount = 0; this.errorCount = 0; this.initializeEndpoints(); } initializeEndpoints() { // OpenAI API if (config.openai.apiKey) { this.registerEndpoint({ name: 'openai', baseUrl: 'https://api.openai.com/v1', timeout: 60000, headers: { 'Authorization': `Bearer ${config.openai.apiKey}`, 'Content-Type': 'application/json' }, rateLimit: { requestsPerMinute: 60, burstSize: 10 } }); } // Perplexity API if (config.perplexity.apiKey) { this.registerEndpoint({ name: 'perplexity', baseUrl: 'https://api.perplexity.ai', timeout: 30000, headers: { 'Authorization': `Bearer ${config.perplexity.apiKey}`, 'Content-Type': 'application/json' }, rateLimit: { requestsPerMinute: 20, burstSize: 5 } }); } // SYIA API if (config.api.baseUrl && config.api.token) { this.registerEndpoint({ name: 'syia', baseUrl: config.api.baseUrl, timeout: 30000, headers: { 'Authorization': `Bearer ${config.api.token}`, 'Content-Type': 'application/json' }, rateLimit: { requestsPerMinute: 100, burstSize: 20 } }); } // Classification Society APIs this.registerEndpoint({ name: 'class_societies', baseUrl: '', timeout: 120000, // Longer timeout for browser automation rateLimit: { requestsPerMinute: 10, burstSize: 2 } }); logger.info(`Initialized ${this.endpoints.size} external API endpoints`); } registerEndpoint(endpoint) { this.endpoints.set(endpoint.name, endpoint); // Create axios instance for this endpoint const axiosConfig = { baseURL: endpoint.baseUrl, timeout: endpoint.timeout || 30000, headers: endpoint.headers || {}, validateStatus: (status) => status < 500 // Don't throw on 4xx errors }; const client = axios.create(axiosConfig); // Add request interceptor for logging and rate limiting client.interceptors.request.use(async (config) => { await this.checkRateLimit(endpoint.name); logger.debug(`External API request`, { endpoint: endpoint.name, method: config.method?.toUpperCase(), url: config.url, headers: this.sanitizeHeaders(config.headers) }); return config; }, (error) => { logger.error(`External API request error`, { endpoint: endpoint.name, error: error.message }); return Promise.reject(error); }); // Add response interceptor for logging client.interceptors.response.use((response) => { logger.debug(`External API response`, { endpoint: endpoint.name, status: response.status, responseTime: response.config.metadata?.endTime && response.config.metadata?.startTime ? response.config.metadata.endTime - response.config.metadata.startTime : undefined }); return response; }, (error) => { logger.error(`External API response error`, { endpoint: endpoint.name, status: error.response?.status, error: error.message }); return Promise.reject(error); }); this.clients.set(endpoint.name, client); this.rateLimitStates.set(endpoint.name, { requests: [], lastRequest: new Date() }); } async performHealthCheck() { const startTime = Date.now(); const healthChecks = []; // Check health of key endpoints const criticalEndpoints = ['syia']; for (const endpointName of criticalEndpoints) { if (this.endpoints.has(endpointName)) { healthChecks.push(this.checkEndpointHealth(endpointName)); } } try { const results = await Promise.allSettled(healthChecks); const unhealthyEndpoints = results .filter(result => result.status === 'fulfilled' && !result.value.isHealthy) .map(result => result.value); const responseTime = Date.now() - startTime; if (unhealthyEndpoints.length > 0) { return { isHealthy: false, lastCheck: new Date(), responseTime, error: 'Some external API endpoints are unhealthy', details: { unhealthyEndpoints } }; } return { isHealthy: true, lastCheck: new Date(), responseTime, details: { requestCount: this.requestCount, errorCount: this.errorCount, endpointsChecked: criticalEndpoints.length } }; } catch (error) { return { isHealthy: false, lastCheck: new Date(), responseTime: Date.now() - startTime, error: error.message }; } } async checkEndpointHealth(endpointName) { try { // For now, just check if we can create a request (basic connectivity) const endpoint = this.endpoints.get(endpointName); if (!endpoint) { return { endpointName, isHealthy: false, error: 'Endpoint not found' }; } // For SYIA API, we could do a simple health check if (endpointName === 'syia') { const response = await this.makeRequest({ endpoint: endpointName, method: 'GET', path: '/health', timeout: 5000, skipSecurityCheck: true }); return { endpointName, isHealthy: response.status < 500 }; } return { endpointName, isHealthy: true }; } catch (error) { return { endpointName, isHealthy: false, error: error.message }; } } async makeRequest(request) { return this.executeWithResilience(async () => { const startTime = Date.now(); try { const endpoint = this.endpoints.get(request.endpoint); if (!endpoint) { throw new ValidationError(`Unknown endpoint: ${request.endpoint}`); } const client = this.clients.get(request.endpoint); if (!client) { throw new ExternalApiError(`Client not initialized for endpoint: ${request.endpoint}`); } // Security validation and sanitization if (!request.skipSecurityCheck) { if (request.data) { const securityCheck = securityManager.performSecurityCheck(request.data, { sanitizationContext: 'external_api' }); if (!securityCheck.isSecure) { throw new ValidationError(`Request data security validation failed: ${securityCheck.issues.join(', ')}`, 'data'); } request.data = securityCheck.sanitizedInput; } if (request.params) { const securityCheck = securityManager.performSecurityCheck(request.params, { sanitizationContext: 'external_api' }); if (!securityCheck.isSecure) { throw new ValidationError(`Request params security validation failed: ${securityCheck.issues.join(', ')}`, 'params'); } request.params = securityCheck.sanitizedInput; } } // Rate limiting check await this.checkRateLimit(request.endpoint); // Prepare request config const requestConfig = { method: request.method, url: request.path, data: request.data, params: request.params, headers: { ...endpoint.headers, ...request.headers }, timeout: request.timeout || endpoint.timeout || 30000, metadata: { startTime } }; // Make the request const response = await client.request(requestConfig); this.requestCount++; const responseTime = Date.now() - startTime; // Check for HTTP errors if (response.status >= 400) { throw new ExternalApiError(`HTTP ${response.status}: ${response.statusText}`, { endpoint: request.endpoint, status: response.status, statusText: response.statusText, responseTime }); } logger.debug(`External API request completed`, { endpoint: request.endpoint, method: request.method, path: request.path, status: response.status, responseTime }); return { data: response.data, status: response.status, statusText: response.statusText, headers: response.headers, responseTime }; } catch (error) { this.errorCount++; const responseTime = Date.now() - startTime; logger.error('External API request failed', { endpoint: request.endpoint, method: request.method, path: request.path, responseTime, error: error.message }); if (error instanceof ValidationError) { throw error; } // Handle axios errors if (error.response) { throw new ExternalApiError(`HTTP ${error.response.status}: ${error.response.statusText}`, { endpoint: request.endpoint, status: error.response.status, statusText: error.response.statusText, responseTime }, this.isRetryableError(error)); } else if (error.request) { throw new ExternalApiError('Network error - no response received', { endpoint: request.endpoint, error: error.message, responseTime }, true // Network errors are generally retryable ); } else { throw new ExternalApiError(`Request setup error: ${error.message}`, { endpoint: request.endpoint, error: error.message, responseTime }); } } }, `makeRequest-${request.endpoint}`, { timeout: request.timeout }); } async checkRateLimit(endpointName) { const endpoint = this.endpoints.get(endpointName); if (!endpoint?.rateLimit) { return; } const state = this.rateLimitStates.get(endpointName); if (!state) { return; } const now = Date.now(); const windowStart = now - 60000; // 1 minute window // Remove old requests outside the window state.requests = state.requests.filter(timestamp => timestamp > windowStart); // Check if we've exceeded the rate limit if (state.requests.length >= endpoint.rateLimit.requestsPerMinute) { const oldestRequest = Math.min(...state.requests); const waitTime = 60000 - (now - oldestRequest); if (waitTime > 0) { logger.warn(`Rate limit exceeded for ${endpointName}, waiting ${waitTime}ms`); await new Promise(resolve => setTimeout(resolve, waitTime)); } } // Add current request state.requests.push(now); state.lastRequest = new Date(); } isRetryableError(error) { // HTTP status codes that are retryable const retryableStatusCodes = [429, 502, 503, 504]; if (error.response?.status && retryableStatusCodes.includes(error.response.status)) { return true; } // Network errors are generally retryable const retryableNetworkCodes = ['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET']; if (retryableNetworkCodes.includes(error.code)) { return true; } // Timeout errors if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) { return true; } return false; } sanitizeHeaders(headers) { if (!headers) return {}; const sanitized = { ...headers }; const sensitiveHeaders = ['authorization', 'x-api-key', 'cookie', 'set-cookie']; for (const header of sensitiveHeaders) { if (sanitized[header]) { sanitized[header] = '[REDACTED]'; } if (sanitized[header.toLowerCase()]) { sanitized[header.toLowerCase()] = '[REDACTED]'; } } return sanitized; } getEndpointStats() { const stats = {}; for (const [name, endpoint] of this.endpoints) { const rateLimitState = this.rateLimitStates.get(name); stats[name] = { baseUrl: endpoint.baseUrl, timeout: endpoint.timeout, hasRateLimit: !!endpoint.rateLimit, recentRequests: rateLimitState?.requests.length || 0, lastRequest: rateLimitState?.lastRequest }; } return stats; } getServiceStats() { const successRate = this.requestCount > 0 ? ((this.requestCount - this.errorCount) / this.requestCount) * 100 : 100; return { requestCount: this.requestCount, errorCount: this.errorCount, successRate: Math.round(successRate * 100) / 100, endpoints: Array.from(this.endpoints.keys()), endpointStats: this.getEndpointStats() }; } async shutdown() { try { // Cancel any pending requests for (const client of this.clients.values()) { // Axios doesn't have a direct cancel all method, but we can set a short timeout client.defaults.timeout = 1; } await super.shutdown(); logger.info('External API service shutdown completed'); } catch (error) { logger.error('Error during external API service shutdown:', error); throw error; } } } export const externalApiService = new ExternalApiService(); //# sourceMappingURL=external-api.js.map