305 lines
9.2 KiB
TypeScript
305 lines
9.2 KiB
TypeScript
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<HttpClientConfig> = {}, 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<T = any>(config: RequestConfig): Promise<HttpResponse<T>> {
|
|
// 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<T>(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<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 delete<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 });
|
|
}
|
|
|
|
/**
|
|
* Execute the actual HTTP request
|
|
*/ private async executeRequest<T>(config: RequestConfig): Promise<HttpResponse<T>> {
|
|
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<string, string> = {};
|
|
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<void> {
|
|
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(),
|
|
};
|
|
}
|
|
}
|