claude-flow
Version:
Enterprise-grade AI agent orchestration with WASM-powered ReasoningBank memory and AgentDB vector database (always uses latest agentic-flow)
616 lines (532 loc) • 18 kB
text/typescript
/**
* Enhanced Claude API client with comprehensive error handling
* Implements exponential backoff, health checks, and improved error messages
*/
import { EventEmitter } from 'events';
import { ILogger } from '../core/logger.js';
import { ConfigManager } from '../config/config-manager.js';
import {
ClaudeAPIError,
ClaudeInternalServerError,
ClaudeServiceUnavailableError,
ClaudeRateLimitError,
ClaudeTimeoutError,
ClaudeNetworkError,
ClaudeAuthenticationError,
ClaudeValidationError,
HealthCheckResult,
getUserFriendlyError,
} from './claude-api-errors.js';
import { circuitBreaker, CircuitBreaker } from '../utils/helpers.js';
import {
ClaudeAPIConfig,
ClaudeModel,
ClaudeMessage,
ClaudeRequest,
ClaudeResponse,
ClaudeStreamEvent,
} from './claude-client.js';
export interface EnhancedClaudeAPIConfig extends ClaudeAPIConfig {
enableHealthCheck?: boolean;
healthCheckInterval?: number;
circuitBreakerThreshold?: number;
circuitBreakerTimeout?: number;
circuitBreakerResetTimeout?: number;
maxRetries?: number;
retryBaseDelay?: number;
retryMaxDelay?: number;
retryJitter?: boolean;
}
export class EnhancedClaudeAPIClient extends EventEmitter {
private config: EnhancedClaudeAPIConfig;
private logger: ILogger;
private configManager: ConfigManager;
private circuitBreaker: CircuitBreaker;
private lastHealthCheck?: HealthCheckResult;
private healthCheckTimer?: NodeJS.Timeout;
constructor(logger: ILogger, configManager: ConfigManager, config?: Partial<EnhancedClaudeAPIConfig>) {
super();
this.logger = logger;
this.configManager = configManager;
this.config = this.loadConfiguration(config);
// Initialize circuit breaker
this.circuitBreaker = circuitBreaker('claude-api', {
threshold: this.config.circuitBreakerThreshold || 5,
timeout: this.config.circuitBreakerTimeout || 60000,
resetTimeout: this.config.circuitBreakerResetTimeout || 300000,
});
// Start health check if enabled
if (this.config.enableHealthCheck) {
this.startHealthCheck();
}
}
/**
* Load configuration with enhanced defaults
*/
private loadConfiguration(overrides?: Partial<EnhancedClaudeAPIConfig>): EnhancedClaudeAPIConfig {
const config: EnhancedClaudeAPIConfig = {
apiKey: '',
apiUrl: 'https://api.anthropic.com/v1/messages',
model: 'claude-3-sonnet-20240229',
temperature: 0.7,
maxTokens: 4096,
topP: 1,
topK: undefined,
systemPrompt: undefined,
timeout: 60000,
retryAttempts: 3,
retryDelay: 1000,
// Enhanced configurations
enableHealthCheck: true,
healthCheckInterval: 300000, // 5 minutes
circuitBreakerThreshold: 5,
circuitBreakerTimeout: 60000,
circuitBreakerResetTimeout: 300000,
maxRetries: 3,
retryBaseDelay: 1000,
retryMaxDelay: 30000,
retryJitter: true,
};
// Load from environment
if (process.env.ANTHROPIC_API_KEY) {
config.apiKey = process.env.ANTHROPIC_API_KEY;
}
// Load from config manager
const claudeConfig = this.configManager.get('claude');
if (claudeConfig) {
Object.assign(config, claudeConfig);
}
// Apply overrides
if (overrides) {
Object.assign(config, overrides);
}
this.validateConfiguration(config);
return config;
}
/**
* Validate configuration
*/
private validateConfiguration(config: EnhancedClaudeAPIConfig): void {
if (!config.apiKey) {
throw new ClaudeAuthenticationError('Claude API key is required. Set ANTHROPIC_API_KEY environment variable.');
}
if (config.temperature !== undefined && (config.temperature < 0 || config.temperature > 1)) {
throw new ClaudeValidationError('Temperature must be between 0 and 1');
}
if (config.maxTokens !== undefined && (config.maxTokens < 1 || config.maxTokens > 100000)) {
throw new ClaudeValidationError('Max tokens must be between 1 and 100000');
}
}
/**
* Start periodic health checks
*/
private startHealthCheck(): void {
this.performHealthCheck(); // Initial check
this.healthCheckTimer = setInterval(
() => this.performHealthCheck(),
this.config.healthCheckInterval || 300000,
);
}
/**
* Perform a health check on the API
*/
async performHealthCheck(): Promise<HealthCheckResult> {
const startTime = Date.now();
try {
// Simple health check request
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout
const response = await fetch(this.config.apiUrl || '', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'x-api-key': this.config.apiKey,
},
body: JSON.stringify({
model: this.config.model,
messages: [{ role: 'user', content: 'Hi' }],
max_tokens: 1,
}),
signal: controller.signal,
});
clearTimeout(timeout);
const latency = Date.now() - startTime;
const healthy = response.ok || response.status === 429; // Rate limit is still "healthy"
this.lastHealthCheck = {
healthy,
latency,
error: healthy ? undefined : `Status: ${response.status}`,
timestamp: new Date(),
};
this.logger.debug('Claude API health check completed', this.lastHealthCheck);
this.emit('health_check', this.lastHealthCheck);
return this.lastHealthCheck;
} catch (error) {
const latency = Date.now() - startTime;
this.lastHealthCheck = {
healthy: false,
latency,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date(),
};
this.logger.warn('Claude API health check failed', this.lastHealthCheck);
this.emit('health_check', this.lastHealthCheck);
return this.lastHealthCheck;
}
}
/**
* Get last health check result
*/
getHealthStatus(): HealthCheckResult | undefined {
return this.lastHealthCheck;
}
/**
* Send a message with enhanced error handling
*/
async sendMessage(
messages: ClaudeMessage[],
options?: {
model?: ClaudeModel;
temperature?: number;
maxTokens?: number;
systemPrompt?: string;
stream?: boolean;
},
): Promise<ClaudeResponse | AsyncIterable<ClaudeStreamEvent>> {
const request: ClaudeRequest = {
model: options?.model || this.config.model || 'claude-3-sonnet-20240229',
messages,
system: options?.systemPrompt || this.config.systemPrompt,
max_tokens: options?.maxTokens || this.config.maxTokens || 4096,
temperature: options?.temperature ?? this.config.temperature,
top_p: this.config.topP,
top_k: this.config.topK,
stream: options?.stream || false,
};
this.logger.debug('Sending Claude API request', {
model: request.model,
temperature: request.temperature,
maxTokens: request.max_tokens,
messageCount: messages.length,
stream: request.stream,
});
try {
// Use circuit breaker for the request
const result = await this.circuitBreaker.execute(async () => {
if (request.stream) {
return this.streamRequestWithRetry(request);
} else {
return this.sendRequestWithRetry(request);
}
});
return result;
} catch (error) {
// Handle circuit breaker open state
if (error instanceof Error && error.message.includes('Circuit breaker')) {
const apiError = new ClaudeServiceUnavailableError(
'Claude API is temporarily unavailable due to repeated failures. Please try again later.',
);
this.handleError(apiError);
throw apiError;
}
throw error;
}
}
/**
* Send request with retry logic and enhanced error handling
*/
private async sendRequestWithRetry(request: ClaudeRequest): Promise<ClaudeResponse> {
let lastError: ClaudeAPIError | undefined;
const maxRetries = this.config.maxRetries || 3;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await this.sendRequestOnce(request);
} catch (error) {
lastError = this.transformError(error);
// Don't retry non-retryable errors
if (!lastError.retryable) {
this.handleError(lastError);
throw lastError;
}
this.logger.warn(
`Claude API request failed (attempt ${attempt + 1}/${maxRetries})`,
{
error: lastError.message,
statusCode: lastError.statusCode,
retryable: lastError.retryable,
},
);
// Don't retry on the last attempt
if (attempt < maxRetries - 1) {
const delay = this.calculateRetryDelay(attempt, lastError);
this.logger.info(`Retrying after ${delay}ms...`);
await this.delay(delay);
}
}
}
this.handleError(lastError!);
throw lastError;
}
/**
* Send a single request
*/
private async sendRequestOnce(request: ClaudeRequest): Promise<ClaudeResponse> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.config.timeout || 60000);
try {
const response = await fetch(this.config.apiUrl || '', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'x-api-key': this.config.apiKey,
},
body: JSON.stringify(request),
signal: controller.signal,
});
clearTimeout(timeout);
// Handle different error status codes
if (!response.ok) {
const errorText = await response.text();
let errorData: any;
try {
errorData = JSON.parse(errorText);
} catch {
errorData = { message: errorText };
}
throw this.createAPIError(response.status, errorData);
}
const data = (await response.json()) as ClaudeResponse;
this.logger.info('Claude API response received', {
model: data.model,
inputTokens: data.usage.input_tokens,
outputTokens: data.usage.output_tokens,
stopReason: data.stop_reason,
});
this.emit('response', data);
return data;
} catch (error) {
clearTimeout(timeout);
// Handle abort/timeout
if (error instanceof Error && error.name === 'AbortError') {
throw new ClaudeTimeoutError(
'Request timed out',
this.config.timeout || 60000,
);
}
throw error;
}
}
/**
* Stream request with retry logic
*/
private async *streamRequestWithRetry(request: ClaudeRequest): AsyncIterable<ClaudeStreamEvent> {
let lastError: ClaudeAPIError | undefined;
const maxRetries = this.config.maxRetries || 3;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
yield* this.streamRequestOnce(request);
return;
} catch (error) {
lastError = this.transformError(error);
if (!lastError.retryable) {
this.handleError(lastError);
throw lastError;
}
this.logger.warn(
`Claude API stream request failed (attempt ${attempt + 1}/${maxRetries})`,
{ error: lastError.message },
);
if (attempt < maxRetries - 1) {
const delay = this.calculateRetryDelay(attempt, lastError);
await this.delay(delay);
}
}
}
this.handleError(lastError!);
throw lastError;
}
/**
* Send a single streaming request
*/
private async *streamRequestOnce(request: ClaudeRequest): AsyncIterable<ClaudeStreamEvent> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), (this.config.timeout || 60000) * 2);
try {
const response = await fetch(this.config.apiUrl || '', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'x-api-key': this.config.apiKey,
},
body: JSON.stringify({ ...request, stream: true }),
signal: controller.signal,
});
if (!response.ok) {
const errorText = await response.text();
let errorData: any;
try {
errorData = JSON.parse(errorText);
} catch {
errorData = { message: errorText };
}
throw this.createAPIError(response.status, errorData);
}
if (!response.body) {
throw new ClaudeAPIError('Response body is null');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const event = JSON.parse(data) as ClaudeStreamEvent;
this.emit('stream_event', event);
yield event;
} catch (e) {
this.logger.warn('Failed to parse stream event', { data, error: e });
}
}
}
}
} finally {
clearTimeout(timeout);
}
}
/**
* Create appropriate error based on status code
*/
private createAPIError(statusCode: number, errorData: any): ClaudeAPIError {
const message = errorData.error?.message || errorData.message || 'Unknown error';
switch (statusCode) {
case 400:
return new ClaudeValidationError(message, errorData);
case 401:
case 403:
return new ClaudeAuthenticationError(message, errorData);
case 429:
const retryAfter = errorData.error?.retry_after;
return new ClaudeRateLimitError(message, retryAfter, errorData);
case 500:
return new ClaudeInternalServerError(message, errorData);
case 503:
return new ClaudeServiceUnavailableError(message, errorData);
default:
return new ClaudeAPIError(message, statusCode, statusCode >= 500, errorData);
}
}
/**
* Transform generic errors to Claude API errors
*/
private transformError(error: unknown): ClaudeAPIError {
if (error instanceof ClaudeAPIError) {
return error;
}
if (error instanceof Error) {
// Network errors
if (error.message.includes('fetch failed') || error.message.includes('ECONNREFUSED')) {
return new ClaudeNetworkError(error.message);
}
// Timeout errors
if (error.name === 'AbortError' || error.message.includes('timeout')) {
return new ClaudeTimeoutError(error.message, this.config.timeout || 60000);
}
}
return new ClaudeAPIError(
error instanceof Error ? error.message : String(error),
undefined,
true, // Assume unknown errors are retryable
);
}
/**
* Calculate retry delay with exponential backoff and jitter
*/
private calculateRetryDelay(attempt: number, error: ClaudeAPIError): number {
// If rate limit error with retry-after header, use that
if (error instanceof ClaudeRateLimitError && error.retryAfter) {
return error.retryAfter * 1000; // Convert to milliseconds
}
const baseDelay = this.config.retryBaseDelay || 1000;
const maxDelay = this.config.retryMaxDelay || 30000;
// Exponential backoff: delay = baseDelay * (2 ^ attempt)
let delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
// Add jitter to prevent thundering herd
if (this.config.retryJitter) {
const jitter = Math.random() * 0.3 * delay; // Up to 30% jitter
delay = delay + jitter;
}
return Math.floor(delay);
}
/**
* Handle errors with user-friendly messages and logging
*/
private handleError(error: ClaudeAPIError): void {
const errorInfo = getUserFriendlyError(error);
this.logger.error(`${errorInfo.title}: ${errorInfo.message}`, {
error: error.message,
code: error.code,
statusCode: error.statusCode,
retryable: error.retryable,
details: error.details,
});
// Log suggestions in debug mode
if (this.logger.level === 'debug' && errorInfo.suggestions.length > 0) {
this.logger.debug('Suggestions to resolve the issue:', errorInfo.suggestions);
}
this.emit('error', {
error,
userFriendly: errorInfo,
});
}
/**
* Helper method for simple completions with error handling
*/
async complete(
prompt: string,
options?: {
model?: ClaudeModel;
temperature?: number;
maxTokens?: number;
systemPrompt?: string;
},
): Promise<string> {
try {
const messages: ClaudeMessage[] = [{ role: 'user', content: prompt }];
const response = (await this.sendMessage(messages, options)) as ClaudeResponse;
return response.content[0].text;
} catch (error) {
if (error instanceof ClaudeAPIError) {
const errorInfo = getUserFriendlyError(error);
throw new Error(`${errorInfo.title}: ${errorInfo.message}`);
}
throw error;
}
}
/**
* Delay helper
*/
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Clean up resources
*/
destroy(): void {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = undefined;
}
this.removeAllListeners();
}
}