/**
* HTTP client with rate limiting, retry, and observability
*
* Provides a resilient HTTP client for external API calls with:
* - Rate limiting integration
* - Automatic retries with exponential backoff
* - Request/response logging
* - User agent rotation (optional)
* - Timeout handling
*/
import { createLogger } from './logger.js';
import { getRateLimiter } from './rate-limiter.js';
import { withRetry, RetryableErrors, type RetryOptions } from './retry.js';
import { ExternalApiError, TimeoutError } from './errors.js';
import { getConfig } from '../config/index.js';
const logger = createLogger('http-client');
/**
* HTTP request options
*/
export interface HttpRequestOptions {
/** HTTP method (default: GET) */
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
/** Request headers */
headers?: Record<string, string>;
/** Request body (will be JSON stringified if object) */
body?: unknown;
/** Request timeout in ms (default: from config) */
timeoutMs?: number;
/** Rate limiter source key */
rateLimitSource?: string;
/** Retry options */
retry?: RetryOptions;
/** Skip rate limiting */
skipRateLimit?: boolean;
/** Skip retry logic */
skipRetry?: boolean;
}
/**
* HTTP response wrapper
*/
export interface HttpResponse<T = unknown> {
status: number;
statusText: string;
headers: Record<string, string>;
data: T;
durationMs: number;
}
/**
* Common user agents for rotation
*/
const USER_AGENTS = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
];
/**
* Get a random user agent
*/
function getRandomUserAgent(): string {
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)] ?? USER_AGENTS[0]!;
}
/**
* HTTP error with status code
*/
export class HttpError extends Error {
readonly status: number;
readonly statusText: string;
readonly responseBody?: string;
constructor(status: number, statusText: string, responseBody?: string) {
super(`HTTP ${status}: ${statusText}`);
this.name = 'HttpError';
this.status = status;
this.statusText = statusText;
this.responseBody = responseBody;
}
}
/**
* Make an HTTP request with rate limiting and retry
*/
export async function httpRequest<T = unknown>(
url: string,
options: HttpRequestOptions = {}
): Promise<HttpResponse<T>> {
const config = getConfig();
const startTime = Date.now();
const {
method = 'GET',
headers = {},
body,
timeoutMs = config.requestTimeoutMs,
rateLimitSource,
retry = {},
skipRateLimit = false,
skipRetry = false,
} = options;
// Apply rate limiting
if (!skipRateLimit && rateLimitSource) {
const rateLimiter = getRateLimiter();
await rateLimiter.waitForSlot(rateLimitSource);
}
// Build request headers
const requestHeaders: Record<string, string> = {
'User-Agent': getRandomUserAgent(),
Accept: 'application/json',
...headers,
};
if (body && !requestHeaders['Content-Type']) {
requestHeaders['Content-Type'] = 'application/json';
}
// Build request body
const requestBody =
body && typeof body === 'object' ? JSON.stringify(body) : (body as string | undefined);
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const executeRequest = async (): Promise<HttpResponse<T>> => {
try {
logger.debug('HTTP request', { method, url });
const response = await fetch(url, {
method,
headers: requestHeaders,
body: requestBody,
signal: controller.signal,
});
clearTimeout(timeoutId);
// Extract response headers
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
// Handle error responses
if (!response.ok) {
const responseBody = await response.text();
// Record failure for rate limiting
if (rateLimitSource) {
const rateLimiter = getRateLimiter();
if (response.status === 429 || response.status >= 500) {
rateLimiter.recordFailure(rateLimitSource);
}
}
throw new HttpError(response.status, response.statusText, responseBody);
}
// Parse response
const contentType = response.headers.get('content-type') ?? '';
let data: T;
if (contentType.includes('application/json')) {
data = (await response.json()) as T;
} else {
data = (await response.text()) as T;
}
// Record success for rate limiting
if (rateLimitSource) {
const rateLimiter = getRateLimiter();
rateLimiter.recordSuccess(rateLimitSource);
}
const durationMs = Date.now() - startTime;
logger.debug('HTTP response', { method, url, status: response.status, durationMs });
return {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
data,
durationMs,
};
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new TimeoutError(`HTTP ${method} ${url}`, timeoutMs);
}
if (error instanceof HttpError) {
throw error;
}
throw new ExternalApiError(
new URL(url).hostname,
error instanceof Error ? error.message : String(error),
error instanceof Error ? error : undefined
);
}
};
// Execute with or without retry
if (skipRetry) {
return executeRequest();
}
return withRetry(executeRequest, {
maxAttempts: 3,
isRetryable: RetryableErrors.isTransientError,
...retry,
});
}
/**
* Convenience methods
*/
export const http = {
get: <T = unknown>(url: string, options?: Omit<HttpRequestOptions, 'method' | 'body'>) =>
httpRequest<T>(url, { ...options, method: 'GET' }),
post: <T = unknown>(url: string, body?: unknown, options?: Omit<HttpRequestOptions, 'method'>) =>
httpRequest<T>(url, { ...options, method: 'POST', body }),
put: <T = unknown>(url: string, body?: unknown, options?: Omit<HttpRequestOptions, 'method'>) =>
httpRequest<T>(url, { ...options, method: 'PUT', body }),
patch: <T = unknown>(url: string, body?: unknown, options?: Omit<HttpRequestOptions, 'method'>) =>
httpRequest<T>(url, { ...options, method: 'PATCH', body }),
delete: <T = unknown>(url: string, options?: Omit<HttpRequestOptions, 'method' | 'body'>) =>
httpRequest<T>(url, { ...options, method: 'DELETE' }),
};