import type { Logger } from '@stock-bot/logger'; import type { HttpClientConfig, RequestConfig, HttpResponse, } from './types'; import { HttpError } from './types'; import { ProxyManager } from './proxy-manager'; import { AdapterFactory } from './adapters/index'; export class HttpClient { private readonly config: HttpClientConfig; private readonly logger?: Logger; constructor(config: HttpClientConfig = {}, logger?: Logger) { this.config = config; this.logger = logger?.child('http-client'); } // Convenience methods async get(url: string, config: Omit = {}): Promise> { return this.request({ ...config, method: 'GET', url }); } async post(url: string, data?: any, config: Omit = {}): Promise> { return this.request({ ...config, method: 'POST', url, data }); } async put(url: string, data?: any, config: Omit = {}): Promise> { return this.request({ ...config, method: 'PUT', url, data }); } async del(url: string, config: Omit = {}): Promise> { return this.request({ ...config, method: 'DELETE', url }); } async patch(url: string, data?: any, config: Omit = {}): Promise> { return this.request({ ...config, method: 'PATCH', url, data }); } /** * Main request method - clean and simple */ async request(config: RequestConfig): Promise> { const finalConfig = this.mergeConfig(config); const startTime = Date.now(); this.logger?.debug('Making HTTP request', { method: finalConfig.method, url: finalConfig.url, hasProxy: !!finalConfig.proxy }); try { const response = await this.executeRequest(finalConfig); response.responseTime = Date.now() - startTime; this.logger?.debug('HTTP request successful', { method: finalConfig.method, url: finalConfig.url, status: response.status, responseTime: response.responseTime, }); return response; } catch (error) { if( this.logger?.getServiceName() === 'proxy-service' ) { this.logger?.debug('HTTP request failed', { method: finalConfig.method, url: finalConfig.url, error: (error as Error).message, }); }else{ this.logger?.warn('HTTP request failed', { method: finalConfig.method, url: finalConfig.url, error: (error as Error).message, }); } throw error; } } /** * Execute request with timeout handling - no race conditions */ private async executeRequest(config: RequestConfig): Promise> { const timeout = config.timeout ?? this.config.timeout ?? 30000; const controller = new AbortController(); const startTime = Date.now(); let timeoutId: NodeJS.Timeout | undefined; // Set up timeout // Create a timeout promise that will reject const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { const elapsed = Date.now() - startTime; this.logger?.debug('Request timeout triggered', { url: config.url, method: config.method, timeout, elapsed }); // Attempt to abort (may or may not work with Bun) controller.abort(); // Force rejection regardless of signal behavior reject(new HttpError(`Request timeout after ${timeout}ms (elapsed: ${elapsed}ms)`)); }, timeout); }); try { // Get the appropriate adapter const adapter = AdapterFactory.getAdapter(config); const response = await Promise.race([ adapter.request(config, controller.signal), timeoutPromise ]); this.logger?.debug('Adapter request successful', { url: config.url, elapsedMs: Date.now() - startTime }); // Clear timeout on success clearTimeout(timeoutId); return response; } catch (error) { const elapsed = Date.now() - startTime; this.logger?.debug('Adapter failed successful', { url: config.url, elapsedMs: Date.now() - startTime }); clearTimeout(timeoutId); // Handle timeout if (controller.signal.aborted) { throw new HttpError(`Request timeout after ${timeout}ms`); } // Re-throw other errors if (error instanceof HttpError) { throw error; } throw new HttpError(`Request failed: ${(error as Error).message}`); } } /** * Merge configs with defaults */ private mergeConfig(config: RequestConfig): RequestConfig { return { ...config, headers: { ...this.config.headers, ...config.headers }, timeout: config.timeout ?? this.config.timeout, }; } }