fixed httpclient
This commit is contained in:
parent
a282dac6cd
commit
557c157228
10 changed files with 603 additions and 427 deletions
|
|
@ -9,8 +9,8 @@ import type {
|
|||
import {
|
||||
HttpError,
|
||||
TimeoutError,
|
||||
HttpClientConfigSchema,
|
||||
RequestConfigSchema,
|
||||
validateHttpClientConfig,
|
||||
validateRequestConfig,
|
||||
} from './types.js';
|
||||
import { RateLimiter } from './rate-limiter.js';
|
||||
import { ProxyManager } from './proxy-manager.js';
|
||||
|
|
@ -19,10 +19,9 @@ 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 = HttpClientConfigSchema.parse(config);
|
||||
this.config = validateHttpClientConfig(config);
|
||||
this.logger = logger;
|
||||
|
||||
// Initialize rate limiter if configured
|
||||
|
|
@ -38,10 +37,9 @@ export class HttpClient {
|
|||
|
||||
/**
|
||||
* Make an HTTP request
|
||||
*/
|
||||
async request<T = any>(config: RequestConfig): Promise<HttpResponse<T>> {
|
||||
*/ async request<T = any>(config: RequestConfig): Promise<HttpResponse<T>> {
|
||||
// Validate request configuration
|
||||
const validatedConfig = RequestConfigSchema.parse(config);
|
||||
const validatedConfig = validateRequestConfig(config);
|
||||
|
||||
// Merge with default configuration
|
||||
const finalConfig = this.mergeConfig(validatedConfig);
|
||||
|
|
@ -54,10 +52,8 @@ export class HttpClient {
|
|||
this.logger?.debug('Making HTTP request', {
|
||||
method: finalConfig.method,
|
||||
url: finalConfig.url
|
||||
});
|
||||
|
||||
let lastError: Error | undefined;
|
||||
const maxRetries = finalConfig.retries ?? this.config.retries;
|
||||
}); let lastError: Error | undefined;
|
||||
const maxRetries = finalConfig.retries ?? 3;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
|
|
@ -99,7 +95,7 @@ export class HttpClient {
|
|||
|
||||
// Wait before retrying (except on last attempt)
|
||||
if (attempt < maxRetries) {
|
||||
const delay = finalConfig.retryDelay ?? this.config.retryDelay;
|
||||
const delay = finalConfig.retryDelay ?? 1000;
|
||||
await this.sleep(delay * Math.pow(2, attempt)); // Exponential backoff
|
||||
}
|
||||
}
|
||||
|
|
@ -133,10 +129,9 @@ export class HttpClient {
|
|||
|
||||
/**
|
||||
* Execute the actual HTTP request
|
||||
*/
|
||||
private async executeRequest<T>(config: RequestConfig): Promise<HttpResponse<T>> {
|
||||
*/ private async executeRequest<T>(config: RequestConfig): Promise<HttpResponse<T>> {
|
||||
const url = this.buildUrl(config.url);
|
||||
const timeout = config.timeout ?? this.config.timeout;
|
||||
const timeout = config.timeout ?? 30000;
|
||||
|
||||
// Create abort controller for timeout
|
||||
const abortController = new AbortController();
|
||||
|
|
@ -144,13 +139,21 @@ export class HttpClient {
|
|||
abortController.abort();
|
||||
}, timeout);
|
||||
|
||||
try {
|
||||
// Prepare request options
|
||||
try { // Prepare request options
|
||||
const requestOptions: RequestInit = {
|
||||
method: config.method,
|
||||
headers: config.headers,
|
||||
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') {
|
||||
|
|
@ -229,31 +232,44 @@ export class HttpClient {
|
|||
|
||||
/**
|
||||
* Merge request config with default config
|
||||
*/
|
||||
private mergeConfig(config: RequestConfig): RequestConfig {
|
||||
*/ private mergeConfig(config: RequestConfig): RequestConfig {
|
||||
return {
|
||||
...config,
|
||||
headers: {
|
||||
...this.config.defaultHeaders,
|
||||
...config.headers,
|
||||
},
|
||||
timeout: config.timeout ?? this.config.timeout,
|
||||
retries: config.retries ?? this.config.retries,
|
||||
retryDelay: config.retryDelay ?? this.config.retryDelay,
|
||||
validateStatus: config.validateStatus ?? this.config.validateStatus,
|
||||
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) {
|
||||
return new URL(url, this.config.baseURL).toString();
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { SocksProxyAgent } from 'socks-proxy-agent';
|
||||
import type { ProxyConfig } from './types.js';
|
||||
import { validateProxyConfig } from './types.js';
|
||||
|
||||
export class ProxyManager {
|
||||
/**
|
||||
|
|
@ -28,21 +29,11 @@ export class ProxyManager {
|
|||
throw new Error(`Unsupported proxy type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate proxy configuration
|
||||
*/
|
||||
static validateConfig(proxy: ProxyConfig): void {
|
||||
if (!proxy.host) {
|
||||
throw new Error('Proxy host is required');
|
||||
}
|
||||
|
||||
if (!proxy.port || proxy.port < 1 || proxy.port > 65535) {
|
||||
throw new Error('Invalid proxy port');
|
||||
}
|
||||
|
||||
if (!['http', 'https', 'socks4', 'socks5'].includes(proxy.type)) {
|
||||
throw new Error(`Invalid proxy type: ${proxy.type}`);
|
||||
}
|
||||
// Use the centralized validation function
|
||||
validateProxyConfig(proxy);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,56 +1,51 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// HTTP Methods
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
|
||||
|
||||
// Proxy configuration
|
||||
export const ProxyConfigSchema = z.object({
|
||||
type: z.enum(['http', 'https', 'socks4', 'socks5']),
|
||||
host: z.string(),
|
||||
port: z.number().min(1).max(65535),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ProxyConfig = z.infer<typeof ProxyConfigSchema>;
|
||||
export interface ProxyConfig {
|
||||
type: 'http' | 'https' | 'socks4' | 'socks5';
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// Rate limiting configuration
|
||||
export const RateLimitConfigSchema = z.object({
|
||||
maxRequests: z.number().min(1),
|
||||
windowMs: z.number().min(1),
|
||||
skipSuccessfulRequests: z.boolean().default(false),
|
||||
skipFailedRequests: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type RateLimitConfig = z.infer<typeof RateLimitConfigSchema>;
|
||||
export interface RateLimitConfig {
|
||||
maxRequests: number;
|
||||
windowMs: number;
|
||||
skipSuccessfulRequests?: boolean;
|
||||
skipFailedRequests?: boolean;
|
||||
}
|
||||
|
||||
// HTTP client configuration
|
||||
export const HttpClientConfigSchema = z.object({
|
||||
baseURL: z.string().url().optional(),
|
||||
timeout: z.number().min(1).default(30000), // 30 seconds default
|
||||
retries: z.number().min(0).default(3),
|
||||
retryDelay: z.number().min(0).default(1000), // 1 second default
|
||||
proxy: ProxyConfigSchema.optional(),
|
||||
rateLimit: RateLimitConfigSchema.optional(),
|
||||
defaultHeaders: z.record(z.string()).default({}),
|
||||
validateStatus: z.function().args(z.number()).returns(z.boolean()).optional(),
|
||||
});
|
||||
|
||||
export type HttpClientConfig = z.infer<typeof HttpClientConfigSchema>;
|
||||
export interface HttpClientConfig {
|
||||
baseURL?: string;
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
retryDelay?: number;
|
||||
proxy?: ProxyConfig;
|
||||
rateLimit?: RateLimitConfig;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
validateStatus?: (status: number) => boolean;
|
||||
}
|
||||
|
||||
// Request configuration
|
||||
export const RequestConfigSchema = z.object({
|
||||
method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']).default('GET'),
|
||||
url: z.string(),
|
||||
headers: z.record(z.string()).default({}).optional(),
|
||||
body: z.any().optional(),
|
||||
timeout: z.number().min(1).optional(),
|
||||
retries: z.number().min(0).optional(),
|
||||
retryDelay: z.number().min(0).optional(),
|
||||
validateStatus: z.function().args(z.number()).returns(z.boolean()).optional(),
|
||||
});
|
||||
|
||||
export type RequestConfig = z.infer<typeof RequestConfigSchema>;
|
||||
export interface RequestConfig {
|
||||
method?: HttpMethod;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
retryDelay?: number;
|
||||
validateStatus?: (status: number) => boolean;
|
||||
params?: Record<string, any>;
|
||||
auth?: {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Response type
|
||||
export interface HttpResponse<T = any> {
|
||||
|
|
@ -91,3 +86,275 @@ export class RateLimitError extends HttpError {
|
|||
this.name = 'RateLimitError';
|
||||
}
|
||||
}
|
||||
|
||||
// Validation functions
|
||||
export function validateProxyConfig(config: unknown): ProxyConfig {
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new Error('Proxy configuration must be an object');
|
||||
}
|
||||
|
||||
const proxy = config as Record<string, unknown>;
|
||||
|
||||
// Validate type
|
||||
if (!proxy.type || !['http', 'https', 'socks4', 'socks5'].includes(proxy.type as string)) {
|
||||
throw new Error('Proxy type must be one of: http, https, socks4, socks5');
|
||||
}
|
||||
|
||||
// Validate host
|
||||
if (!proxy.host || typeof proxy.host !== 'string' || proxy.host.trim().length === 0) {
|
||||
throw new Error('Proxy host is required');
|
||||
}
|
||||
|
||||
// Validate port
|
||||
if (typeof proxy.port !== 'number' || proxy.port < 1 || proxy.port > 65535 || !Number.isInteger(proxy.port)) {
|
||||
throw new Error('Invalid proxy port');
|
||||
}
|
||||
|
||||
// Validate optional auth
|
||||
if (proxy.username !== undefined && typeof proxy.username !== 'string') {
|
||||
throw new Error('Proxy username must be a string');
|
||||
}
|
||||
|
||||
if (proxy.password !== undefined && typeof proxy.password !== 'string') {
|
||||
throw new Error('Proxy password must be a string');
|
||||
}
|
||||
|
||||
return {
|
||||
type: proxy.type as ProxyConfig['type'],
|
||||
host: proxy.host.trim(),
|
||||
port: proxy.port,
|
||||
username: proxy.username as string | undefined,
|
||||
password: proxy.password as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateRateLimitConfig(config: unknown): RateLimitConfig {
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new Error('Rate limit configuration must be an object');
|
||||
}
|
||||
|
||||
const rateLimit = config as Record<string, unknown>;
|
||||
|
||||
// Validate maxRequests
|
||||
if (typeof rateLimit.maxRequests !== 'number' || rateLimit.maxRequests < 1 || !Number.isInteger(rateLimit.maxRequests)) {
|
||||
throw new Error('maxRequests must be a positive integer');
|
||||
}
|
||||
|
||||
// Validate windowMs
|
||||
if (typeof rateLimit.windowMs !== 'number' || rateLimit.windowMs < 1 || !Number.isInteger(rateLimit.windowMs)) {
|
||||
throw new Error('windowMs must be a positive integer');
|
||||
}
|
||||
|
||||
// Validate optional booleans
|
||||
if (rateLimit.skipSuccessfulRequests !== undefined && typeof rateLimit.skipSuccessfulRequests !== 'boolean') {
|
||||
throw new Error('skipSuccessfulRequests must be a boolean');
|
||||
}
|
||||
|
||||
if (rateLimit.skipFailedRequests !== undefined && typeof rateLimit.skipFailedRequests !== 'boolean') {
|
||||
throw new Error('skipFailedRequests must be a boolean');
|
||||
}
|
||||
|
||||
return {
|
||||
maxRequests: rateLimit.maxRequests,
|
||||
windowMs: rateLimit.windowMs,
|
||||
skipSuccessfulRequests: rateLimit.skipSuccessfulRequests ?? false,
|
||||
skipFailedRequests: rateLimit.skipFailedRequests ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateHttpClientConfig(config: unknown = {}): HttpClientConfig {
|
||||
if (!config || typeof config !== 'object') {
|
||||
config = {};
|
||||
}
|
||||
|
||||
const cfg = config as Record<string, unknown>;
|
||||
// Validate baseURL
|
||||
let baseURL: string | undefined;
|
||||
if (cfg.baseURL !== undefined) {
|
||||
if (typeof cfg.baseURL !== 'string') {
|
||||
throw new Error('baseURL must be a string');
|
||||
}
|
||||
// Simple URL validation - just ensure it starts with http:// or https://
|
||||
if (cfg.baseURL && !cfg.baseURL.startsWith('http://') && !cfg.baseURL.startsWith('https://')) {
|
||||
baseURL = `https://${cfg.baseURL}`;
|
||||
} else {
|
||||
baseURL = cfg.baseURL;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate timeout
|
||||
const timeout = cfg.timeout !== undefined
|
||||
? validatePositiveNumber(cfg.timeout, 'timeout')
|
||||
: 30000;
|
||||
|
||||
// Validate retries
|
||||
const retries = cfg.retries !== undefined
|
||||
? validateNonNegativeInteger(cfg.retries, 'retries')
|
||||
: 3;
|
||||
|
||||
// Validate retryDelay
|
||||
const retryDelay = cfg.retryDelay !== undefined
|
||||
? validateNonNegativeNumber(cfg.retryDelay, 'retryDelay')
|
||||
: 1000;
|
||||
|
||||
// Validate headers
|
||||
let defaultHeaders: Record<string, string> = {};
|
||||
if (cfg.defaultHeaders !== undefined) {
|
||||
if (!cfg.defaultHeaders || typeof cfg.defaultHeaders !== 'object') {
|
||||
throw new Error('defaultHeaders must be an object');
|
||||
}
|
||||
defaultHeaders = cfg.defaultHeaders as Record<string, string>;
|
||||
// Validate header values are strings
|
||||
for (const [key, value] of Object.entries(defaultHeaders)) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`Header value for '${key}' must be a string`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate validateStatus function
|
||||
let validateStatus: ((status: number) => boolean) | undefined;
|
||||
if (cfg.validateStatus !== undefined) {
|
||||
if (typeof cfg.validateStatus !== 'function') {
|
||||
throw new Error('validateStatus must be a function');
|
||||
}
|
||||
validateStatus = cfg.validateStatus as (status: number) => boolean;
|
||||
}
|
||||
|
||||
// Validate proxy
|
||||
let proxy: ProxyConfig | undefined;
|
||||
if (cfg.proxy !== undefined) {
|
||||
proxy = validateProxyConfig(cfg.proxy);
|
||||
}
|
||||
|
||||
// Validate rateLimit
|
||||
let rateLimit: RateLimitConfig | undefined;
|
||||
if (cfg.rateLimit !== undefined) {
|
||||
rateLimit = validateRateLimitConfig(cfg.rateLimit);
|
||||
}
|
||||
|
||||
return {
|
||||
baseURL,
|
||||
timeout,
|
||||
retries,
|
||||
retryDelay,
|
||||
defaultHeaders,
|
||||
validateStatus,
|
||||
proxy,
|
||||
rateLimit,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateRequestConfig(config: unknown): RequestConfig {
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new Error('Request configuration must be an object');
|
||||
}
|
||||
|
||||
const req = config as Record<string, unknown>;
|
||||
|
||||
// Validate URL (required)
|
||||
if (!req.url || typeof req.url !== 'string' || req.url.trim().length === 0) {
|
||||
throw new Error('url is required and must be a non-empty string');
|
||||
}
|
||||
|
||||
// Validate method
|
||||
const validMethods: HttpMethod[] = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
|
||||
const method: HttpMethod = req.method !== undefined
|
||||
? validateEnum(req.method, validMethods, 'method') as HttpMethod
|
||||
: 'GET';
|
||||
|
||||
// Validate headers
|
||||
let headers: Record<string, string> = {};
|
||||
if (req.headers !== undefined) {
|
||||
if (typeof req.headers !== 'object' || req.headers === null) {
|
||||
throw new Error('headers must be an object');
|
||||
}
|
||||
headers = req.headers as Record<string, string>;
|
||||
}
|
||||
|
||||
// Validate optional numbers
|
||||
const timeout = req.timeout !== undefined
|
||||
? validatePositiveNumber(req.timeout, 'timeout')
|
||||
: undefined;
|
||||
|
||||
const retries = req.retries !== undefined
|
||||
? validateNonNegativeInteger(req.retries, 'retries')
|
||||
: undefined;
|
||||
|
||||
const retryDelay = req.retryDelay !== undefined
|
||||
? validateNonNegativeNumber(req.retryDelay, 'retryDelay')
|
||||
: undefined;
|
||||
|
||||
// Validate validateStatus function
|
||||
let validateStatus: ((status: number) => boolean) | undefined;
|
||||
if (req.validateStatus !== undefined) {
|
||||
if (typeof req.validateStatus !== 'function') {
|
||||
throw new Error('validateStatus must be a function');
|
||||
}
|
||||
validateStatus = req.validateStatus as (status: number) => boolean;
|
||||
}
|
||||
|
||||
// Validate params
|
||||
let params: Record<string, any> | undefined;
|
||||
if (req.params !== undefined) {
|
||||
if (typeof req.params !== 'object' || req.params === null) {
|
||||
throw new Error('params must be an object');
|
||||
}
|
||||
params = req.params as Record<string, any>;
|
||||
}
|
||||
|
||||
// Validate auth
|
||||
let auth: { username: string; password: string } | undefined;
|
||||
if (req.auth !== undefined) {
|
||||
if (!req.auth || typeof req.auth !== 'object') {
|
||||
throw new Error('auth must be an object');
|
||||
}
|
||||
const authObj = req.auth as Record<string, unknown>;
|
||||
if (typeof authObj.username !== 'string' || typeof authObj.password !== 'string') {
|
||||
throw new Error('auth must have username and password strings');
|
||||
}
|
||||
auth = { username: authObj.username, password: authObj.password };
|
||||
}
|
||||
|
||||
return {
|
||||
method,
|
||||
url: req.url.trim(),
|
||||
headers,
|
||||
body: req.body,
|
||||
timeout,
|
||||
retries,
|
||||
retryDelay,
|
||||
validateStatus,
|
||||
params,
|
||||
auth,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper validation functions
|
||||
function validatePositiveNumber(value: unknown, fieldName: string): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${fieldName} must be a positive number`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateNonNegativeNumber(value: unknown, fieldName: string): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${fieldName} must be a non-negative number`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateNonNegativeInteger(value: unknown, fieldName: string): number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`${fieldName} must be a non-negative integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateEnum<T>(value: unknown, validValues: T[], fieldName: string): T {
|
||||
if (!validValues.includes(value as T)) {
|
||||
throw new Error(`${fieldName} must be one of: ${validValues.join(', ')}`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue