293 lines
9.8 KiB
TypeScript
293 lines
9.8 KiB
TypeScript
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<T = any>(url: string, config: Omit<RequestConfig, 'method' | 'url'> = {}): Promise<HttpResponse<T>> {
|
|
return this.request<T>({ ...config, method: 'GET', url });
|
|
}
|
|
|
|
async post<T = any>(url: string, body?: any, config: Omit<RequestConfig, 'method' | 'url' | 'body'> = {}): Promise<HttpResponse<T>> {
|
|
return this.request<T>({ ...config, method: 'POST', url, body });
|
|
}
|
|
|
|
async put<T = any>(url: string, body?: any, config: Omit<RequestConfig, 'method' | 'url' | 'body'> = {}): Promise<HttpResponse<T>> {
|
|
return this.request<T>({ ...config, method: 'PUT', url, body });
|
|
}
|
|
|
|
async del<T = any>(url: string, config: Omit<RequestConfig, 'method' | 'url'> = {}): Promise<HttpResponse<T>> {
|
|
return this.request<T>({ ...config, method: 'DELETE', url });
|
|
}
|
|
|
|
async patch<T = any>(url: string, body?: any, config: Omit<RequestConfig, 'method' | 'url' | 'body'> = {}): Promise<HttpResponse<T>> {
|
|
return this.request<T>({ ...config, method: 'PATCH', url, body });
|
|
} /**
|
|
* Main request method - unified and simplified
|
|
*/
|
|
async request<T = any>(config: RequestConfig): Promise<HttpResponse<T>> {
|
|
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<T>(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<T>(config: RequestConfig, useBunFetch: boolean): Promise<HttpResponse<T>> {
|
|
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<never>((_, 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<T>(config, controller.signal)
|
|
: this.axiosRequest<T>(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<T>(config: RequestConfig, signal: AbortSignal): Promise<HttpResponse<T>> {
|
|
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<T>(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<T>(config: RequestConfig, signal: AbortSignal): Promise<HttpResponse<T>> {
|
|
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<T> = await axios.request(axiosProxy);
|
|
return this.parseAxiosResponse<T>(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<T>(response: Response): Promise<HttpResponse<T>> {
|
|
const data = await this.parseResponseBody<T>(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<T>(response: AxiosResponse<T>): HttpResponse<T> {
|
|
const headers = response.headers as Record<string, string>;
|
|
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<T>(response: Response): Promise<T> {
|
|
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,
|
|
};
|
|
}
|
|
}
|