import type { Logger } from '@stock-bot/logger'; import type { HttpClientConfig, RequestConfig, HttpResponse, } from './types.js'; import { HttpError, TimeoutError, validateHttpClientConfig, validateRequestConfig, } from './types.js'; import { RateLimiter } from './rate-limiter.js'; import { ProxyManager } from './proxy-manager.js'; export class HttpClient { private readonly config: HttpClientConfig; private readonly rateLimiter?: RateLimiter; private readonly logger?: Logger; constructor(config: Partial = {}, logger?: Logger) { // Validate and set default configuration this.config = validateHttpClientConfig(config); this.logger = logger; // Initialize rate limiter if configured if (this.config.rateLimit) { this.rateLimiter = new RateLimiter(this.config.rateLimit); } // Validate proxy configuration if provided if (this.config.proxy) { ProxyManager.validateConfig(this.config.proxy); } } /** * Make an HTTP request */ async request(config: RequestConfig): Promise> { // Validate request configuration const validatedConfig = validateRequestConfig(config); // Merge with default configuration const finalConfig = this.mergeConfig(validatedConfig); // Check rate limiting if (this.rateLimiter) { await this.rateLimiter.checkRateLimit(); } this.logger?.debug('Making HTTP request', { method: finalConfig.method, url: finalConfig.url }); let lastError: Error | undefined; const maxRetries = finalConfig.retries ?? 3; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const response = await this.executeRequest(finalConfig); // Record successful request for rate limiting if (this.rateLimiter) { this.rateLimiter.recordRequest(true); } this.logger?.debug('HTTP request successful', { method: finalConfig.method, url: finalConfig.url, status: response.status, attempt: attempt + 1, }); return response; } catch (error) { lastError = error as Error; // Record failed request for rate limiting if (this.rateLimiter) { this.rateLimiter.recordRequest(false); } this.logger?.warn('HTTP request failed', { method: finalConfig.method, url: finalConfig.url, attempt: attempt + 1, error: lastError.message, }); // Don't retry on certain errors if (error instanceof TimeoutError || (error instanceof HttpError && error.status && error.status < 500)) { break; } // Wait before retrying (except on last attempt) if (attempt < maxRetries) { const delay = finalConfig.retryDelay ?? 1000; await this.sleep(delay * Math.pow(2, attempt)); // Exponential backoff } } } throw lastError; } /** * Convenience methods for common HTTP methods */ async get(url: string, config: Omit = {}): Promise> { return this.request({ ...config, method: 'GET', url }); } async post(url: string, body?: any, config: Omit = {}): Promise> { return this.request({ ...config, method: 'POST', url, body }); } async put(url: string, body?: any, config: Omit = {}): Promise> { return this.request({ ...config, method: 'PUT', url, body }); } async delete(url: string, config: Omit = {}): Promise> { return this.request({ ...config, method: 'DELETE', url }); } async patch(url: string, body?: any, config: Omit = {}): Promise> { return this.request({ ...config, method: 'PATCH', url, body }); } /** * Execute the actual HTTP request */ private async executeRequest(config: RequestConfig): Promise> { const url = this.buildUrl(config.url); const timeout = config.timeout ?? 30000; // Create abort controller for timeout const abortController = new AbortController(); const timeoutId = setTimeout(() => { abortController.abort(); }, timeout); try { // Prepare request options const requestOptions: RequestInit = { method: config.method, headers: config.headers ? {...config.headers} : {}, signal: abortController.signal, }; // Add basic auth if provided if (config.auth) { const authValue = `Basic ${Buffer.from(`${config.auth.username}:${config.auth.password}`).toString('base64')}`; requestOptions.headers = { ...requestOptions.headers, 'Authorization': authValue }; } // Add body for non-GET requests if (config.body && config.method !== 'GET' && config.method !== 'HEAD') { if (typeof config.body === 'object') { requestOptions.body = JSON.stringify(config.body); requestOptions.headers = { 'Content-Type': 'application/json', ...requestOptions.headers, }; } else { requestOptions.body = config.body; } } // Add proxy agent if configured if (this.config.proxy) { const agent = ProxyManager.createAgent(this.config.proxy); (requestOptions as any).agent = agent; } // Make the request const response = await fetch(url, requestOptions); // Clear timeout clearTimeout(timeoutId); // Check if response status is valid const validateStatus = config.validateStatus ?? this.config.validateStatus ?? this.defaultValidateStatus; if (!validateStatus(response.status)) { throw new HttpError( `Request failed with status ${response.status}`, response.status, undefined, config ); } // Parse response const responseHeaders: Record = {}; response.headers.forEach((value, key) => { responseHeaders[key] = value; }); let data: T; const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/json')) { data = await response.json(); } else if (contentType.includes('text/')) { data = await response.text() as any; } else { data = await response.arrayBuffer() as any; } return { data, status: response.status, statusText: response.statusText, headers: responseHeaders, config, }; } catch (error) { clearTimeout(timeoutId); if (abortController.signal.aborted) { throw new TimeoutError(config, timeout); } if (error instanceof HttpError) { throw error; } throw new HttpError(`Request failed: ${(error as Error).message}`, undefined, undefined, config); } } /** * Merge request config with default config */ private mergeConfig(config: RequestConfig): RequestConfig { return { ...config, headers: { ...this.config.defaultHeaders, ...config.headers, }, timeout: config.timeout ?? this.config.timeout ?? 30000, retries: config.retries ?? this.config.retries ?? 3, retryDelay: config.retryDelay ?? this.config.retryDelay ?? 1000, validateStatus: config.validateStatus ?? this.config.validateStatus ?? this.defaultValidateStatus, }; } /** * Build full URL from base URL and request URL */ private buildUrl(url: string): string { // If it's already a full URL, return it as is if (url.startsWith('http://') || url.startsWith('https://')) { return url; } // If we have a baseURL, combine them if (this.config.baseURL) { try { // Try to use URL constructor return new URL(url, this.config.baseURL).toString(); } catch (e) { // Fall back to string concatenation if URL constructor fails const base = this.config.baseURL.endsWith('/') ? this.config.baseURL : `${this.config.baseURL}/`; const path = url.startsWith('/') ? url.substring(1) : url; return `${base}${path}`; } } // No baseURL, so prepend https:// if it's a domain-like string if (!url.includes('://') && url.includes('.')) { return `https://${url}`; } return url; } /** * Default status validator */ private defaultValidateStatus(status: number): boolean { return status >= 200 && status < 300; } /** * Sleep utility for retries */ private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Get current rate limiting status */ getRateLimitStatus() { if (!this.rateLimiter) { return null; } return { currentCount: this.rateLimiter.getCurrentCount(), maxRequests: this.config.rateLimit!.maxRequests, windowMs: this.config.rateLimit!.windowMs, timeUntilReset: this.rateLimiter.getTimeUntilReset(), }; } }