removed doodoo projects, getting ready for restructuring
This commit is contained in:
parent
674112af05
commit
5c64b1ccf8
82 changed files with 5 additions and 8917 deletions
|
|
@ -1,182 +0,0 @@
|
|||
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
|
||||
import { BunHttpClient, HttpClientError, TimeoutError } from "../src";
|
||||
|
||||
// Mock GlobalFetch to avoid making real network requests
|
||||
const mockFetchSuccess = mock(() =>
|
||||
Promise.resolve(new Response(
|
||||
JSON.stringify({ result: "success" }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
))
|
||||
);
|
||||
|
||||
const mockFetchFailure = mock(() =>
|
||||
Promise.resolve(new Response(
|
||||
JSON.stringify({ error: "Not found" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } }
|
||||
))
|
||||
);
|
||||
|
||||
const mockFetchTimeout = mock(() => {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
const error = new Error("Timeout");
|
||||
error.name = "AbortError";
|
||||
reject(error);
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BunHttpClient", () => {
|
||||
let client: BunHttpClient;
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh client for each test
|
||||
client = new BunHttpClient({
|
||||
baseURL: "https://api.example.com",
|
||||
timeout: 1000,
|
||||
retries: 1
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Cleanup after each test
|
||||
await client.close();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("should make successful GET requests", async () => {
|
||||
global.fetch = mockFetchSuccess;
|
||||
|
||||
const response = await client.get("/users");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data).toEqual({ result: "success" });
|
||||
expect(mockFetchSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should handle failed requests", async () => {
|
||||
global.fetch = mockFetchFailure;
|
||||
|
||||
try {
|
||||
await client.get("/missing");
|
||||
expect("Should have thrown").toBe("But didn't");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(HttpClientError);
|
||||
expect(error.status).toBe(404);
|
||||
}
|
||||
|
||||
expect(mockFetchFailure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should handle request timeouts", async () => {
|
||||
global.fetch = mockFetchTimeout;
|
||||
|
||||
try {
|
||||
await client.get("/slow");
|
||||
expect("Should have thrown").toBe("But didn't");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(TimeoutError);
|
||||
}
|
||||
});
|
||||
|
||||
test("should build full URLs properly", async () => {
|
||||
global.fetch = mockFetchSuccess;
|
||||
|
||||
await client.get("/users/123");
|
||||
expect(mockFetchSuccess).toHaveBeenCalledWith(
|
||||
"https://api.example.com/users/123",
|
||||
expect.objectContaining({
|
||||
method: "GET"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should make POST requests with body", async () => {
|
||||
global.fetch = mockFetchSuccess;
|
||||
|
||||
const data = { name: "John", email: "john@example.com" };
|
||||
await client.post("/users", data);
|
||||
|
||||
expect(mockFetchSuccess).toHaveBeenCalledWith(
|
||||
"https://api.example.com/users",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should provide convenience methods for all HTTP verbs", async () => {
|
||||
global.fetch = mockFetchSuccess;
|
||||
|
||||
await client.get("/users");
|
||||
await client.post("/users", { name: "Test" });
|
||||
await client.put("/users/1", { name: "Updated" });
|
||||
await client.patch("/users/1", { status: "active" });
|
||||
await client.delete("/users/1");
|
||||
await client.head("/users");
|
||||
await client.options("/users");
|
||||
|
||||
expect(mockFetchSuccess).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
|
||||
test("should merge config options correctly", async () => {
|
||||
global.fetch = mockFetchSuccess;
|
||||
|
||||
await client.get("/users", {
|
||||
headers: { "X-Custom": "Value" },
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
expect(mockFetchSuccess).toHaveBeenCalledWith(
|
||||
"https://api.example.com/users",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"X-Custom": "Value"
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle absolute URLs", async () => {
|
||||
global.fetch = mockFetchSuccess;
|
||||
|
||||
await client.get("https://other-api.com/endpoint");
|
||||
|
||||
expect(mockFetchSuccess).toHaveBeenCalledWith(
|
||||
"https://other-api.com/endpoint",
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
test("should update configuration", async () => {
|
||||
global.fetch = mockFetchSuccess;
|
||||
|
||||
client.setBaseURL("https://new-api.com");
|
||||
client.setDefaultHeaders({ "Authorization": "Bearer token" });
|
||||
client.setTimeout(2000);
|
||||
|
||||
await client.get("/resource");
|
||||
|
||||
expect(mockFetchSuccess).toHaveBeenCalledWith(
|
||||
"https://new-api.com/resource",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Authorization": "Bearer token"
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should get connection stats", async () => {
|
||||
global.fetch = mockFetchSuccess;
|
||||
|
||||
await client.get("/users");
|
||||
const stats = client.getStats();
|
||||
|
||||
expect(stats).toHaveProperty("successfulRequests", 1);
|
||||
expect(stats).toHaveProperty("activeConnections");
|
||||
expect(stats).toHaveProperty("averageResponseTime");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
import { EventEmitter } from 'eventemitter3';
|
||||
import {
|
||||
HttpClientConfig,
|
||||
RequestConfig,
|
||||
HttpResponse,
|
||||
ConnectionStats,
|
||||
HttpClientError,
|
||||
TimeoutError
|
||||
} from './types';
|
||||
import { ConnectionPool } from './ConnectionPool';
|
||||
import { RetryHandler } from './RetryHandler';
|
||||
|
||||
export class BunHttpClient extends EventEmitter {
|
||||
private connectionPool: ConnectionPool;
|
||||
private retryHandler: RetryHandler;
|
||||
private defaultConfig: Required<HttpClientConfig>;
|
||||
|
||||
constructor(config: HttpClientConfig = {}) {
|
||||
super();
|
||||
|
||||
this.defaultConfig = {
|
||||
baseURL: '',
|
||||
timeout: 30000,
|
||||
headers: {},
|
||||
retries: 3,
|
||||
retryDelay: 1000,
|
||||
maxConcurrency: 10,
|
||||
keepAlive: true,
|
||||
validateStatus: (status: number) => status < 400,
|
||||
...config
|
||||
};
|
||||
|
||||
this.connectionPool = new ConnectionPool({
|
||||
maxConnections: this.defaultConfig.maxConcurrency,
|
||||
maxConnectionsPerHost: Math.ceil(this.defaultConfig.maxConcurrency / 4),
|
||||
keepAlive: this.defaultConfig.keepAlive,
|
||||
maxIdleTime: 60000,
|
||||
connectionTimeout: this.defaultConfig.timeout
|
||||
});
|
||||
|
||||
this.retryHandler = new RetryHandler({
|
||||
maxRetries: this.defaultConfig.retries,
|
||||
baseDelay: this.defaultConfig.retryDelay,
|
||||
maxDelay: 30000,
|
||||
exponentialBackoff: true
|
||||
});
|
||||
|
||||
// Forward events from connection pool and retry handler
|
||||
this.connectionPool.on('response', (data) => this.emit('response', data));
|
||||
this.connectionPool.on('error', (data) => this.emit('error', data));
|
||||
this.retryHandler.on('retryAttempt', (data) => this.emit('retryAttempt', data));
|
||||
this.retryHandler.on('retrySuccess', (data) => this.emit('retrySuccess', data));
|
||||
this.retryHandler.on('retryExhausted', (data) => this.emit('retryExhausted', data));
|
||||
}
|
||||
|
||||
async request<T = any>(config: RequestConfig): Promise<HttpResponse<T>> {
|
||||
const fullConfig = this.mergeConfig(config);
|
||||
|
||||
return this.retryHandler.execute(async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// Add timing metadata
|
||||
fullConfig.metadata = {
|
||||
...fullConfig.metadata,
|
||||
startTime
|
||||
};
|
||||
|
||||
const response = await this.connectionPool.request(fullConfig);
|
||||
return response as HttpResponse<T>;
|
||||
|
||||
} catch (error: any) {
|
||||
// Convert fetch errors to our error types
|
||||
if (error.name === 'AbortError') {
|
||||
throw new TimeoutError(fullConfig, fullConfig.timeout || this.defaultConfig.timeout);
|
||||
}
|
||||
|
||||
// Re-throw as HttpClientError if not already
|
||||
if (!(error instanceof HttpClientError)) {
|
||||
const httpError = new HttpClientError(
|
||||
error.message || 'Request failed',
|
||||
error.code,
|
||||
error.status,
|
||||
error.response,
|
||||
fullConfig
|
||||
);
|
||||
throw httpError;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}, fullConfig);
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
async get<T = any>(url: string, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, url, method: 'GET' });
|
||||
}
|
||||
|
||||
async post<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, url, method: 'POST', body: data });
|
||||
}
|
||||
|
||||
async put<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, url, method: 'PUT', body: data });
|
||||
}
|
||||
|
||||
async patch<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, url, method: 'PATCH', body: data });
|
||||
}
|
||||
|
||||
async delete<T = any>(url: string, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, url, method: 'DELETE' });
|
||||
}
|
||||
|
||||
async head<T = any>(url: string, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, url, method: 'HEAD' });
|
||||
}
|
||||
|
||||
async options<T = any>(url: string, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, url, method: 'OPTIONS' });
|
||||
}
|
||||
|
||||
private mergeConfig(config: RequestConfig): RequestConfig {
|
||||
return {
|
||||
...config,
|
||||
timeout: this.defaultConfig.timeout,
|
||||
retries: this.defaultConfig.retries,
|
||||
headers: { ...this.defaultConfig.headers, ...config.headers },
|
||||
validateStatus: this.defaultConfig.validateStatus,
|
||||
url: this.buildUrl(config.url),
|
||||
};
|
||||
}
|
||||
|
||||
private buildUrl(url: string): string {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (this.defaultConfig.baseURL) {
|
||||
const baseURL = this.defaultConfig.baseURL.replace(/\/$/, '');
|
||||
const path = url.replace(/^\//, '');
|
||||
return `${baseURL}/${path}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
// Configuration methods
|
||||
setBaseURL(baseURL: string): void {
|
||||
this.defaultConfig.baseURL = baseURL;
|
||||
}
|
||||
|
||||
setDefaultHeaders(headers: Record<string, string>): void {
|
||||
this.defaultConfig.headers = { ...this.defaultConfig.headers, ...headers };
|
||||
}
|
||||
|
||||
setTimeout(timeout: number): void {
|
||||
this.defaultConfig.timeout = timeout;
|
||||
}
|
||||
|
||||
setMaxConcurrency(maxConcurrency: number): void {
|
||||
this.defaultConfig.maxConcurrency = maxConcurrency;
|
||||
}
|
||||
|
||||
// Statistics and monitoring
|
||||
getStats(): ConnectionStats {
|
||||
return this.connectionPool.getStats();
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<{ healthy: boolean; details: any }> {
|
||||
return this.connectionPool.healthCheck();
|
||||
}
|
||||
|
||||
// Lifecycle management
|
||||
async close(): Promise<void> {
|
||||
await this.connectionPool.close();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
// Create a new instance with different configuration
|
||||
create(config: HttpClientConfig): BunHttpClient {
|
||||
const mergedConfig = { ...this.defaultConfig, ...config };
|
||||
return new BunHttpClient(mergedConfig);
|
||||
}
|
||||
|
||||
// Interceptor-like functionality through events
|
||||
onRequest(handler: (config: RequestConfig) => RequestConfig | Promise<RequestConfig>): void {
|
||||
this.on('beforeRequest', handler);
|
||||
}
|
||||
|
||||
onResponse(handler: (response: HttpResponse) => HttpResponse | Promise<HttpResponse>): void {
|
||||
this.on('afterResponse', handler);
|
||||
}
|
||||
|
||||
onError(handler: (error: any) => void): void {
|
||||
this.on('requestError', handler);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,331 +0,0 @@
|
|||
import { EventEmitter } from 'eventemitter3';
|
||||
import type {
|
||||
ConnectionPoolConfig,
|
||||
ConnectionStats,
|
||||
QueuedRequest,
|
||||
RequestConfig
|
||||
} from './types';
|
||||
|
||||
export class ConnectionPool extends EventEmitter {
|
||||
private activeConnections = new Map<string, number>();
|
||||
private requestQueue: QueuedRequest[] = [];
|
||||
private stats = {
|
||||
totalConnections: 0,
|
||||
activeRequests: 0,
|
||||
successfulRequests: 0,
|
||||
failedRequests: 0,
|
||||
totalResponseTime: 0,
|
||||
requestCount: 0,
|
||||
startTime: Date.now()
|
||||
};
|
||||
private isProcessingQueue = false;
|
||||
private queueProcessor?: NodeJS.Timeout;
|
||||
|
||||
constructor(private config: ConnectionPoolConfig) {
|
||||
super();
|
||||
this.startQueueProcessor();
|
||||
}
|
||||
|
||||
async request(requestConfig: RequestConfig): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const host = this.extractHost(requestConfig.url);
|
||||
const queuedRequest: QueuedRequest = {
|
||||
id: this.generateRequestId(),
|
||||
config: requestConfig,
|
||||
resolve,
|
||||
reject,
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0,
|
||||
host
|
||||
};
|
||||
|
||||
this.requestQueue.push(queuedRequest);
|
||||
this.processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.isProcessingQueue || this.requestQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessingQueue = true;
|
||||
|
||||
while (this.requestQueue.length > 0) {
|
||||
const request = this.requestQueue.shift()!;
|
||||
|
||||
try {
|
||||
const currentConnections = this.activeConnections.get(request.host) || 0;
|
||||
|
||||
// Check per-host connection limits
|
||||
if (currentConnections >= this.config.maxConnectionsPerHost) {
|
||||
this.requestQueue.unshift(request);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check global connection limit
|
||||
const totalActive = Array.from(this.activeConnections.values())
|
||||
.reduce((sum, count) => sum + count, 0);
|
||||
|
||||
if (totalActive >= this.config.maxConnections) {
|
||||
this.requestQueue.unshift(request);
|
||||
break;
|
||||
}
|
||||
|
||||
// Execute the request
|
||||
this.executeRequest(request);
|
||||
|
||||
} catch (error) {
|
||||
request.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
this.isProcessingQueue = false;
|
||||
}
|
||||
|
||||
private async executeRequest(request: QueuedRequest): Promise<void> {
|
||||
const { host, config } = request;
|
||||
|
||||
// Increment active connections
|
||||
this.activeConnections.set(host, (this.activeConnections.get(host) || 0) + 1);
|
||||
this.stats.activeRequests++;
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// Build the full URL
|
||||
const url = this.buildUrl(config.url, config);
|
||||
|
||||
// Create abort controller for timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = config.timeout ? setTimeout(() => {
|
||||
controller.abort();
|
||||
}, config.timeout) : undefined;
|
||||
|
||||
// Make the fetch request
|
||||
const response = await fetch(url, {
|
||||
method: config.method || 'GET',
|
||||
headers: this.buildHeaders(config.headers),
|
||||
body: this.buildBody(config.body),
|
||||
signal: controller.signal,
|
||||
// Bun-specific optimizations
|
||||
keepalive: this.config.keepAlive,
|
||||
});
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
// Check if response is considered successful
|
||||
const isSuccess = config.validateStatus
|
||||
? config.validateStatus(response.status)
|
||||
: response.status < 400;
|
||||
|
||||
if (!isSuccess) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Parse response data
|
||||
const data = await this.parseResponse(response);
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// Update stats
|
||||
this.updateStats(true, duration);
|
||||
|
||||
// Build response object
|
||||
const httpResponse = {
|
||||
data,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: this.parseHeaders(response.headers),
|
||||
config,
|
||||
timing: {
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
duration
|
||||
}
|
||||
};
|
||||
|
||||
this.emit('response', { host, response: httpResponse });
|
||||
request.resolve(httpResponse);
|
||||
|
||||
} catch (error: any) {
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
this.updateStats(false, duration);
|
||||
this.emit('error', { host, error, config });
|
||||
request.reject(error);
|
||||
|
||||
} finally {
|
||||
// Decrement active connections
|
||||
this.activeConnections.set(host, Math.max(0, (this.activeConnections.get(host) || 0) - 1));
|
||||
this.stats.activeRequests = Math.max(0, this.stats.activeRequests - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private buildUrl(url: string, config: RequestConfig): string {
|
||||
// If URL is already absolute, return as-is
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// If no base URL in config, assume it's a relative URL that needs a protocol
|
||||
if (!url.startsWith('/')) {
|
||||
url = '/' + url;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
private buildHeaders(headers?: Record<string, string>): HeadersInit {
|
||||
return {
|
||||
'User-Agent': 'StockBot-HttpClient/1.0',
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
};
|
||||
}
|
||||
|
||||
private buildBody(body: any): BodyInit | undefined {
|
||||
if (!body) return undefined;
|
||||
if (typeof body === 'string') return body;
|
||||
if (body instanceof FormData || body instanceof Blob) return body;
|
||||
if (body instanceof ArrayBuffer || body instanceof Uint8Array) return body;
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
private async parseResponse(response: Response): Promise<any> {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
if (contentType.includes('text/')) {
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
return await response.arrayBuffer();
|
||||
}
|
||||
|
||||
private parseHeaders(headers: Headers): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private extractHost(url: string): string {
|
||||
try {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.host;
|
||||
}
|
||||
return 'default';
|
||||
} catch {
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
private generateRequestId(): string {
|
||||
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
private updateStats(success: boolean, responseTime: number): void {
|
||||
this.stats.requestCount++;
|
||||
this.stats.totalResponseTime += responseTime;
|
||||
|
||||
if (success) {
|
||||
this.stats.successfulRequests++;
|
||||
} else {
|
||||
this.stats.failedRequests++;
|
||||
}
|
||||
}
|
||||
|
||||
private startQueueProcessor(): void {
|
||||
this.queueProcessor = setInterval(() => {
|
||||
if (this.requestQueue.length > 0) {
|
||||
this.processQueue();
|
||||
}
|
||||
}, 10); // Process queue every 10ms for better responsiveness
|
||||
}
|
||||
|
||||
getStats(): ConnectionStats {
|
||||
const totalActive = Array.from(this.activeConnections.values())
|
||||
.reduce((sum, count) => sum + count, 0);
|
||||
|
||||
const averageResponseTime = this.stats.requestCount > 0
|
||||
? this.stats.totalResponseTime / this.stats.requestCount
|
||||
: 0;
|
||||
|
||||
const utilization = this.config.maxConnections > 0
|
||||
? totalActive / this.config.maxConnections
|
||||
: 0;
|
||||
|
||||
const elapsedTimeSeconds = (Date.now() - this.stats.startTime) / 1000;
|
||||
const requestsPerSecond = elapsedTimeSeconds > 0
|
||||
? this.stats.requestCount / elapsedTimeSeconds
|
||||
: 0;
|
||||
|
||||
return {
|
||||
activeConnections: totalActive,
|
||||
totalConnections: this.stats.totalConnections,
|
||||
successfulRequests: this.stats.successfulRequests,
|
||||
failedRequests: this.stats.failedRequests,
|
||||
averageResponseTime,
|
||||
connectionPoolUtilization: utilization,
|
||||
requestsPerSecond
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// Stop queue processor
|
||||
if (this.queueProcessor) {
|
||||
clearInterval(this.queueProcessor);
|
||||
this.queueProcessor = undefined;
|
||||
}
|
||||
|
||||
// Wait for pending requests to complete (with timeout)
|
||||
const timeout = 30000; // 30 seconds
|
||||
const startTime = Date.now();
|
||||
|
||||
while (this.requestQueue.length > 0 && Date.now() - startTime < timeout) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
// Reject remaining requests
|
||||
while (this.requestQueue.length > 0) {
|
||||
const request = this.requestQueue.shift()!;
|
||||
request.reject(new Error('Connection pool closing'));
|
||||
}
|
||||
|
||||
// Clear connections
|
||||
this.activeConnections.clear();
|
||||
this.removeAllListeners();
|
||||
|
||||
this.emit('closed');
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<{ healthy: boolean; details: any }> {
|
||||
const stats = this.getStats();
|
||||
const queueSize = this.requestQueue.length;
|
||||
|
||||
const healthy =
|
||||
stats.connectionPoolUtilization < 0.9 && // Less than 90% utilization
|
||||
queueSize < 100 && // Queue not too large
|
||||
stats.averageResponseTime < 5000; // Average response time under 5 seconds
|
||||
|
||||
return {
|
||||
healthy,
|
||||
details: {
|
||||
stats,
|
||||
queueSize,
|
||||
activeHosts: Array.from(this.activeConnections.keys()),
|
||||
config: this.config
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
import { EventEmitter } from 'eventemitter3';
|
||||
import {
|
||||
RetryConfig,
|
||||
RequestConfig,
|
||||
HttpResponse,
|
||||
RetryExhaustedError
|
||||
} from './types';
|
||||
import { TimeoutError } from './types';
|
||||
|
||||
export class RetryHandler extends EventEmitter {
|
||||
private config: Required<RetryConfig>;
|
||||
|
||||
constructor(config: Partial<RetryConfig> = {}) {
|
||||
super();
|
||||
this.config = {
|
||||
maxRetries: 3,
|
||||
baseDelay: 1000,
|
||||
maxDelay: 30000,
|
||||
exponentialBackoff: true,
|
||||
retryCondition: this.defaultRetryCondition,
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
async execute<T>(
|
||||
operation: () => Promise<HttpResponse<T>>,
|
||||
requestConfig: RequestConfig
|
||||
): Promise<HttpResponse<T>> {
|
||||
let lastError: any;
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt <= this.config.maxRetries) {
|
||||
try {
|
||||
const result = await operation();
|
||||
|
||||
if (attempt > 0) {
|
||||
this.emit('retrySuccess', {
|
||||
requestConfig,
|
||||
attempt,
|
||||
result
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
attempt++;
|
||||
|
||||
// Check if we should retry
|
||||
if (
|
||||
attempt > this.config.maxRetries ||
|
||||
!this.config.retryCondition(error)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Calculate delay
|
||||
const delay = this.calculateDelay(attempt);
|
||||
|
||||
this.emit('retryAttempt', {
|
||||
requestConfig,
|
||||
attempt,
|
||||
error,
|
||||
delay
|
||||
});
|
||||
|
||||
// Wait before retry
|
||||
await this.delay(delay);
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
const finalError = new RetryExhaustedError(requestConfig, attempt, lastError);
|
||||
this.emit('retryExhausted', {
|
||||
requestConfig,
|
||||
attempts: attempt,
|
||||
finalError
|
||||
});
|
||||
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
private calculateDelay(attempt: number): number {
|
||||
if (!this.config.exponentialBackoff) {
|
||||
return this.config.baseDelay;
|
||||
}
|
||||
|
||||
// Exponential backoff with jitter
|
||||
const exponentialDelay = this.config.baseDelay * Math.pow(2, attempt - 1);
|
||||
const jitter = Math.random() * 0.1 * exponentialDelay; // 10% jitter
|
||||
const totalDelay = Math.min(exponentialDelay + jitter, this.config.maxDelay);
|
||||
|
||||
return Math.floor(totalDelay);
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private defaultRetryCondition(error: any): boolean {
|
||||
// Network errors
|
||||
if (error.code === 'ECONNRESET' ||
|
||||
error.code === 'ECONNREFUSED' ||
|
||||
error.code === 'ETIMEDOUT' ||
|
||||
error.code === 'ENOTFOUND') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// HTTP status codes that should be retried
|
||||
const retryableStatuses = [408, 429, 500, 502, 503, 504];
|
||||
if (error.status && retryableStatuses.includes(error.status)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Timeout errors
|
||||
if (error instanceof TimeoutError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
updateConfig(config: Partial<RetryConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
getConfig(): RetryConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
// Main exports
|
||||
export { BunHttpClient } from './BunHttpClient';
|
||||
export { ConnectionPool } from './ConnectionPool';
|
||||
export { RetryHandler } from './RetryHandler';
|
||||
|
||||
// Type exports
|
||||
export type {
|
||||
HttpClientConfig,
|
||||
RequestConfig,
|
||||
HttpResponse,
|
||||
ConnectionPoolConfig,
|
||||
ConnectionStats,
|
||||
QueuedRequest,
|
||||
RetryConfig
|
||||
} from './types';
|
||||
|
||||
// Error exports
|
||||
export {
|
||||
HttpClientError,
|
||||
TimeoutError,
|
||||
RetryExhaustedError
|
||||
} from './types';
|
||||
|
||||
// Default export for convenience
|
||||
export { BunHttpClient as default } from './BunHttpClient';
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
// Type definitions for the HTTP client
|
||||
export interface HttpClientConfig {
|
||||
baseURL?: string;
|
||||
timeout?: number;
|
||||
headers?: Record<string, string>;
|
||||
retries?: number;
|
||||
retryDelay?: number;
|
||||
maxConcurrency?: number;
|
||||
keepAlive?: boolean;
|
||||
validateStatus?: (status: number) => boolean;
|
||||
}
|
||||
|
||||
export interface RequestConfig {
|
||||
url: string;
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
metadata?: Record<string, any>;
|
||||
validateStatus?: (status: number) => boolean;
|
||||
}
|
||||
|
||||
export interface HttpResponse<T = any> {
|
||||
data: T;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
config: RequestConfig;
|
||||
timing: {
|
||||
start: number;
|
||||
end: number;
|
||||
duration: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConnectionPoolConfig {
|
||||
maxConnections: number;
|
||||
maxConnectionsPerHost: number;
|
||||
keepAlive: boolean;
|
||||
maxIdleTime: number;
|
||||
connectionTimeout: number;
|
||||
}
|
||||
|
||||
export interface ConnectionStats {
|
||||
activeConnections: number;
|
||||
totalConnections: number;
|
||||
successfulRequests: number;
|
||||
failedRequests: number;
|
||||
averageResponseTime: number;
|
||||
connectionPoolUtilization: number;
|
||||
requestsPerSecond: number;
|
||||
}
|
||||
|
||||
export interface QueuedRequest {
|
||||
id: string;
|
||||
config: RequestConfig;
|
||||
resolve: (value: HttpResponse) => void;
|
||||
reject: (error: any) => void;
|
||||
timestamp: number;
|
||||
retryCount: number;
|
||||
host: string;
|
||||
}
|
||||
|
||||
export interface RetryConfig {
|
||||
maxRetries: number;
|
||||
baseDelay: number;
|
||||
maxDelay: number;
|
||||
exponentialBackoff: boolean;
|
||||
retryCondition?: (error: any) => boolean;
|
||||
}
|
||||
|
||||
export class HttpClientError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code?: string,
|
||||
public status?: number,
|
||||
public response?: any,
|
||||
public config?: RequestConfig
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HttpClientError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeoutError extends HttpClientError {
|
||||
constructor(config: RequestConfig, timeout: number) {
|
||||
super(`Request timeout after ${timeout}ms`, 'TIMEOUT', undefined, undefined, config);
|
||||
this.name = 'TimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RetryExhaustedError extends HttpClientError {
|
||||
constructor(config: RequestConfig, attempts: number, lastError: any) {
|
||||
super(
|
||||
`Request failed after ${attempts} attempts: ${lastError.message}`,
|
||||
'RETRY_EXHAUSTED',
|
||||
lastError.status,
|
||||
lastError.response,
|
||||
config
|
||||
);
|
||||
this.name = 'RetryExhaustedError';
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue