import axios, { AxiosInstance, AxiosResponse, AxiosError } from 'axios';
import NodeCache from 'node-cache';
import { appConfig } from '../config/index.js';
import { APIError, TimeoutError, RateLimitError, logError } from '../utils/errors.js';
/**
* Rate limiter using token bucket algorithm
*/
class RateLimiter {
private tokens: number;
private lastRefill: number;
constructor(
private maxTokens: number,
private refillRate: number // tokens per minute
) {
this.tokens = maxTokens;
this.lastRefill = Date.now();
}
async waitForToken(): Promise<void> {
this.refillTokens();
if (this.tokens <= 0) {
const waitTime = (60 * 1000) / this.refillRate; // ms to wait for next token
await new Promise(resolve => setTimeout(resolve, waitTime));
return this.waitForToken();
}
this.tokens--;
}
private refillTokens(): void {
const now = Date.now();
const elapsed = now - this.lastRefill;
const tokensToAdd = Math.floor((elapsed / (60 * 1000)) * this.refillRate);
if (tokensToAdd > 0) {
this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd);
this.lastRefill = now;
}
}
}
/**
* HTTP client service with error handling, caching, and rate limiting
*/
export class HttpClientService {
private axiosInstance: AxiosInstance;
private cache: NodeCache;
private rateLimiters: Map<string, RateLimiter> = new Map();
constructor() {
this.cache = new NodeCache({ stdTTL: appConfig.cacheTtl });
this.axiosInstance = axios.create({
timeout: appConfig.apiTimeout,
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Real-Estate-MCP/1.0.0',
},
});
this.setupInterceptors();
this.setupRateLimiters();
}
private setupRateLimiters(): void {
this.rateLimiters.set('zillow', new RateLimiter(
appConfig.zillowRateLimit,
appConfig.zillowRateLimit
));
this.rateLimiters.set('rentcast', new RateLimiter(
appConfig.rentcastRateLimit,
appConfig.rentcastRateLimit
));
}
private setupInterceptors(): void {
// Request interceptor for logging
this.axiosInstance.interceptors.request.use(
(config) => {
if (appConfig.logLevel === 'debug') {
console.log(`HTTP Request: ${config.method?.toUpperCase()} ${config.url}`);
}
return config;
},
(error) => {
logError(error, 'HTTP Request');
return Promise.reject(error);
}
);
// Response interceptor for error handling and logging
this.axiosInstance.interceptors.response.use(
(response: AxiosResponse) => {
if (appConfig.logLevel === 'debug') {
console.log(`HTTP Response: ${response.status} ${response.config.url}`);
}
return response;
},
(error: AxiosError) => {
this.handleHttpError(error);
return Promise.reject(error);
}
);
}
private handleHttpError(error: AxiosError): void {
const { response, code, config } = error;
if (code === 'ECONNABORTED' || code === 'TIMEOUT') {
throw new TimeoutError(
'Request timed out',
config?.timeout
);
}
if (response?.status === 429) {
const retryAfter = response.headers['retry-after']
? parseInt(response.headers['retry-after'] as string)
: undefined;
throw new RateLimitError(
'Rate limit exceeded',
retryAfter
);
}
const statusCode = response?.status;
const provider = this.getProviderFromUrl(config?.url);
throw new APIError(
`HTTP ${statusCode} error from ${provider || 'API'}`,
statusCode,
provider,
error
);
}
private getProviderFromUrl(url?: string): string | undefined {
if (!url) return undefined;
if (url.includes('rapidapi.com') || url.includes('zillow')) {
return 'Zillow';
}
if (url.includes('rentcast.io')) {
return 'RentCast';
}
return 'Unknown';
}
/**
* Make HTTP GET request with caching and rate limiting
*/
async get<T>(
url: string,
options: {
headers?: Record<string, string>;
params?: Record<string, any>;
provider?: 'zillow' | 'rentcast';
cacheTtl?: number;
skipCache?: boolean;
} = {}
): Promise<T> {
const { provider, cacheTtl, skipCache = false, ...axiosOptions } = options;
// Generate cache key
const cacheKey = this.generateCacheKey(url, axiosOptions.params);
// Try cache first (unless skipped)
if (!skipCache) {
const cached = this.cache.get<T>(cacheKey);
if (cached) {
if (appConfig.logLevel === 'debug') {
console.log(`Cache hit for ${url}`);
}
return cached;
}
}
// Apply rate limiting
if (provider) {
const rateLimiter = this.rateLimiters.get(provider);
if (rateLimiter) {
await rateLimiter.waitForToken();
}
}
try {
// Make HTTP request with retry logic
const response = await this.makeRequestWithRetry<T>(url, axiosOptions);
// Cache the response
if (!skipCache) {
this.cache.set(cacheKey, response.data, cacheTtl || appConfig.cacheTtl);
}
return response.data;
} catch (error) {
logError(error as Error, `HTTP GET ${url}`);
throw error;
}
}
/**
* Make HTTP request with exponential backoff retry
*/
private async makeRequestWithRetry<T>(
url: string,
options: any,
maxRetries: number = 3,
currentRetry: number = 0
): Promise<AxiosResponse<T>> {
try {
return await this.axiosInstance.get<T>(url, options);
} catch (error) {
if (currentRetry >= maxRetries) {
throw error;
}
const isRetryableError = this.isRetryableError(error as AxiosError);
if (!isRetryableError) {
throw error;
}
// Exponential backoff: 1s, 2s, 4s
const delayMs = Math.pow(2, currentRetry) * 1000;
await new Promise(resolve => setTimeout(resolve, delayMs));
return this.makeRequestWithRetry<T>(url, options, maxRetries, currentRetry + 1);
}
}
private isRetryableError(error: AxiosError): boolean {
// Retry on network errors or 5xx server errors
return !error.response ||
error.response.status >= 500 ||
error.code === 'ECONNRESET' ||
error.code === 'ENOTFOUND';
}
private generateCacheKey(url: string, params?: Record<string, any>): string {
const paramStr = params ? JSON.stringify(params) : '';
return `${url}:${paramStr}`;
}
/**
* Clear cache for specific pattern or all
*/
clearCache(pattern?: string): void {
if (pattern) {
const keys = this.cache.keys();
keys.forEach(key => {
if (key.includes(pattern)) {
this.cache.del(key);
}
});
} else {
this.cache.flushAll();
}
}
/**
* Get cache stats
*/
getCacheStats(): { hits: number; misses: number; keys: number } {
const stats = this.cache.getStats();
return {
hits: stats.hits,
misses: stats.misses,
keys: stats.keys
};
}
}
// Singleton instance
export const httpClient = new HttpClientService();