import type { Logger } from '@stock-bot/logger'; import type { HttpClientConfig, RequestConfig, HttpResponse, } from './types.js'; import { HttpError } from './types.js'; import { ProxyManager } from './proxy-manager.js'; import axios, { type AxiosResponse, AxiosError } from 'axios'; import { loggingConfig } from '@stock-bot/config'; export class HttpClient { private readonly config: HttpClientConfig; private readonly logger?: Logger; constructor(config: HttpClientConfig = {}, logger?: Logger) { this.config = config; this.logger = logger?.child({ //TODO fix pino levels component: 'http', // level: loggingConfig?.LOG_LEVEL || 'info', }); } // Convenience 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 del(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 }); } /** * Main request method - unified and simplified */ 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 { // Single decision point for proxy type - only request-level proxy const proxy = finalConfig.proxy; const useBunFetch = !proxy || ProxyManager.shouldUseBunFetch(proxy); const response = await this.makeRequest(finalConfig, useBunFetch); const responseTime = Date.now() - startTime; response.responseTime = responseTime; this.logger?.debug('HTTP request successful', { method: finalConfig.method, url: finalConfig.url, status: response.status, responseTime: responseTime, }); return response; } catch (error) { // this.logger?.warn('HTTP request failed', { // method: finalConfig.method, // url: finalConfig.url, // error: (error as Error).message, // }); throw error; } } /** * Unified request method with consolidated timeout handling */ private async makeRequest(config: RequestConfig, useBunFetch: boolean): Promise> { const timeout = config.timeout ?? this.config.timeout ?? 30000; const controller = new AbortController(); // Create timeout promise that rejects with proper error let timeoutId: NodeJS.Timeout | undefined; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { controller.abort(); reject(new HttpError(`Request timeout after ${timeout}ms`)); }, timeout); }); // Create request promise (don't await here!) const requestPromise = useBunFetch ? this.fetchRequest(config, controller.signal) : this.axiosRequest(config, controller.signal); try { // Race the promises const result = await Promise.race([requestPromise, timeoutPromise]); if (timeoutId) clearTimeout(timeoutId); return result; } catch (error) { // If it's our timeout error, handle it if (error instanceof HttpError && error.message.includes('timeout')) { this.logger?.warn('Request timed out', { method: config.method, url: config.url, timeout: timeout, }); throw error; // Re-throw the timeout error } // Handle other errors (network, parsing, etc.) throw error; } } /** * Bun fetch implementation (simplified) */ private async fetchRequest(config: RequestConfig, signal: AbortSignal): Promise> { try { const options = this.buildFetchOptions(config, signal); this.logger?.debug('Making request with fetch: ', { url: config.url, options }) // console.log(options) const response = await fetch(config.url, options); // console.log('Fetch response:', response.status); return this.parseFetchResponse(response); } catch (error) { throw signal.aborted ? new HttpError(`Request timeout`) : new HttpError(`Request failed: ${(error as Error).message}`); } } /** * Axios implementation (for SOCKS proxies) */ private async axiosRequest(config: RequestConfig, signal: AbortSignal): Promise> { if(config.proxy) { try { const axiosProxy = await ProxyManager.createAxiosConfig(config.proxy); axiosProxy.url = config.url; axiosProxy.method = config.method || 'GET'; // console.log(axiosProxy) // const axiosConfig = { // ...axiosProxy, // url: config.url, // method: config.method || 'GET', // // headers: config.headers || {}, // // data: config.body, // // signal, // Axios supports AbortSignal // }; // console.log('Making request with Axios: ', axiosConfig ); const response: AxiosResponse = await axios.request(axiosProxy); return this.parseAxiosResponse(response); } catch (error) { console.error('Axios request error:', error); // Handle AbortSignal timeout if (signal.aborted) { throw new HttpError(`Request timeout`); } // Handle Axios timeout errors if (error instanceof AxiosError && error.code === 'ECONNABORTED') { throw new HttpError(`Request timeout`); } throw new HttpError(`Request failed: ${(error as Error).message}`); } } else { throw new HttpError(`Request failed: No proxy configured, use fetch instead`); } } /** * Build fetch options (extracted for clarity) */ private buildFetchOptions(config: RequestConfig, signal: AbortSignal): RequestInit { const options: RequestInit = { method: config.method || 'GET', headers: config.headers || {}, signal, }; // Add body if (config.body && config.method !== 'GET') { if (typeof config.body === 'object') { options.body = JSON.stringify(config.body); options.headers = { 'Content-Type': 'application/json', ...options.headers }; } else { options.body = config.body; } } // Add proxy (HTTP/HTTPS only) - request level only if (config.proxy && ProxyManager.shouldUseBunFetch(config.proxy)) { (options as any).proxy = ProxyManager.createProxyUrl(config.proxy); } return options; } /** * Build Axios options (for reference, though we're creating instance in ProxyManager) */ private buildAxiosOptions(config: RequestConfig, signal: AbortSignal): any { const options: any = { method: config.method || 'GET', headers: config.headers || {}, signal, // Axios supports AbortSignal timeout: config.timeout || 30000, maxRedirects: 5, validateStatus: () => true // Don't throw on HTTP errors }; // Add body if (config.body && config.method !== 'GET') { if (typeof config.body === 'object') { options.data = config.body; options.headers = { 'Content-Type': 'application/json', ...options.headers }; } else { options.data = config.body; options.headers = { 'Content-Type': 'text/plain', ...options.headers }; } } return options; } /** * Parse fetch response (simplified) */ private async parseFetchResponse(response: Response): Promise> { const data = await this.parseResponseBody(response); const headers = Object.fromEntries(response.headers.entries()); if (!response.ok) { throw new HttpError( `Request failed with status ${response.status}`, response.status, { data, status: response.status, headers, ok: response.ok } ); } return { data, status: response.status, headers, ok: response.ok }; } /** * Parse Axios response */ private parseAxiosResponse(response: AxiosResponse): HttpResponse { const headers = response.headers as Record; const status = response.status; const ok = status >= 200 && status < 300; const data = response.data; if (!ok) { throw new HttpError( `Request failed with status ${status}`, status, { data, status, headers, ok } ); } return { data, status, headers, ok }; } /** * Unified body parsing (works for fetch response) */ private async parseResponseBody(response: Response): Promise { const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/json')) { return response.json(); } else if (contentType.includes('text/')) { return response.text() as any; } else { return response.arrayBuffer() as any; } } /** * Merge configs - request-level proxy only */ private mergeConfig(config: RequestConfig): RequestConfig { return { ...config, headers: { ...this.config.headers, ...config.headers }, timeout: config.timeout ?? this.config.timeout, }; } }