/** * HTTP Client with Rate Limiting and Retry Logic * * Provides comprehensive HTTP client functionality: * - Rate limiting with configurable strategies * - Retry mechanisms with exponential backoff * - Request/response interception * - Error handling and recovery * - Timeout management */ const DEFAULT_RETRY_AFTER = 60000; // 60 seconds /** * HTTP Client with advanced features */ class HttpClient { constructor(options = {}) { this.options = { timeout: options.timeout || 30000, retries: options.retries || 3, retryDelay: options.retryDelay || 1000, maxRetryDelay: options.maxRetryDelay || 30000, retryOn: options.retryOn || [408, 429, 500, 502, 503, 504], ...options, }; this.rateLimits = {}; this.interceptors = { request: [], response: [], }; } /** * Add request interceptor * @param {Function} interceptor - Function to modify request */ addRequestInterceptor(interceptor) { this.interceptors.request.push(interceptor); } /** * Add response interceptor * @param {Function} interceptor - Function to modify response */ addResponseInterceptor(interceptor) { this.interceptors.response.push(interceptor); } /** * Make HTTP request with rate limiting and retry logic * @param {string|Object} urlOrConfig - URL string or config object * @param {Object} config - Request configuration * @returns {Promise} Response promise */ async request(urlOrConfig, config = {}) { const requestConfig = typeof urlOrConfig === "string" ? { url: urlOrConfig, ...config } : { ...urlOrConfig, ...config }; // Apply request interceptors let processedConfig = requestConfig; for (const interceptor of this.interceptors.request) { processedConfig = await interceptor(processedConfig); } return this.executeWithRetry(processedConfig); } /** * Execute request with retry logic * @param {Object} config - Request configuration * @param {number} attempt - Current attempt number * @returns {Promise} Response promise */ async executeWithRetry(config, attempt = 1) { const { method = "GET", url } = config; // Check rate limits if (this.isRateLimited(method, url)) { const retryAfter = this.getRetryAfter(method, url); throw new Error(`Rate limited. Retry after ${retryAfter}ms`); } try { const response = await this.executeRequest(config); // Update rate limits from response this.updateRateLimitsFromResponse(response, method, url); // Apply response interceptors let processedResponse = response; for (const interceptor of this.interceptors.response) { processedResponse = await interceptor(processedResponse); } return processedResponse; } catch (error) { // Update rate limits from error response if (error.response) { this.updateRateLimitsFromResponse(error.response, method, url); } // Check if we should retry if (this.shouldRetry(error, attempt)) { const delay = this.calculateRetryDelay(attempt, error); await this.sleep(delay); return this.executeWithRetry(config, attempt + 1); } throw error; } } /** * Execute the actual HTTP request * @param {Object} config - Request configuration * @returns {Promise} Response promise */ async executeRequest(config) { const { method = "GET", url, headers = {}, data, timeout = this.options.timeout, } = config; // Use fetch if available, otherwise fallback to XHR if (typeof fetch !== "undefined") { return this.executeFetchRequest(config); } else { return this.executeXhrRequest(config); } } /** * Execute request using fetch API * @param {Object} config - Request configuration * @returns {Promise} Response promise */ async executeFetchRequest(config) { const { method = "GET", url, headers = {}, data, timeout = this.options.timeout, } = config; const fetchOptions = { method, headers, body: data && method !== "GET" && method !== "HEAD" ? data : undefined, }; // Create timeout promise const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error("Request timeout")), timeout); }); try { const response = await Promise.race([ fetch(url, fetchOptions), timeoutPromise, ]); return { status: response.status, statusText: response.statusText, headers: this.extractFetchHeaders(response.headers), data: await this.parseFetchResponse(response), config, }; } catch (error) { throw this.createRequestError(error, config); } } /** * Execute request using XMLHttpRequest * @param {Object} config - Request configuration * @returns {Promise} Response promise */ executeXhrRequest(config) { return new Promise((resolve, reject) => { const { method = "GET", url, headers = {}, data, timeout = this.options.timeout, } = config; const xhr = new XMLHttpRequest(); xhr.timeout = timeout; xhr.open(method, url); // Set headers Object.entries(headers).forEach(([key, value]) => { xhr.setRequestHeader(key, value); }); xhr.onload = () => { const response = { status: xhr.status, statusText: xhr.statusText, headers: this.parseXhrHeaders(xhr.getAllResponseHeaders()), data: xhr.responseText, config, }; if (xhr.status >= 200 && xhr.status < 300) { resolve(response); } else { reject( this.createRequestError( new Error(`HTTP ${xhr.status}`), config, response, ), ); } }; xhr.onerror = () => { reject(this.createRequestError(new Error("Network error"), config)); }; xhr.ontimeout = () => { reject(this.createRequestError(new Error("Request timeout"), config)); }; xhr.send(data || null); }); } /** * Check if request is rate limited * @param {string} method - HTTP method * @param {string} url - Request URL * @returns {boolean} True if rate limited */ isRateLimited(method, url) { const key = this.getRateLimitKey(method, url); const rateLimitTime = this.getRateLimitTime(key); return rateLimitTime > Date.now(); } /** * Get retry after time for rate limited request * @param {string} method - HTTP method * @param {string} url - Request URL * @returns {number} Retry after time in milliseconds */ getRetryAfter(method, url) { const key = this.getRateLimitKey(method, url); const rateLimitTime = this.getRateLimitTime(key); return Math.max(0, rateLimitTime - Date.now()); } /** * Update rate limits based on response headers * @param {Object} response - HTTP response * @param {string} method - HTTP method * @param {string} url - Request URL */ updateRateLimitsFromResponse(response, method, url) { const { status, headers } = response; const now = Date.now(); const rateLimitHeader = headers["x-sentry-rate-limits"]; const retryAfterHeader = headers["retry-after"]; if (rateLimitHeader) { this.parseRateLimitHeader(rateLimitHeader, now); } else if (retryAfterHeader) { const retryAfter = this.parseRetryAfter(retryAfterHeader, now); this.rateLimits.all = now + retryAfter; } else if (status === 429) { this.rateLimits.all = now + DEFAULT_RETRY_AFTER; } } /** * Parse retry-after header * @param {string} retryAfter - Retry-after header value * @param {number} now - Current timestamp * @returns {number} Retry after time in milliseconds */ parseRetryAfter(retryAfter, now = Date.now()) { const seconds = parseInt(`${retryAfter}`, 10); if (!isNaN(seconds)) { return seconds * 1000; } const date = Date.parse(`${retryAfter}`); if (!isNaN(date)) { return date - now; } return DEFAULT_RETRY_AFTER; } /** * Parse rate limit header (Sentry format) * @param {string} header - Rate limit header value * @param {number} now - Current timestamp */ parseRateLimitHeader(header, now) { const limits = header.trim().split(","); for (const limit of limits) { const [seconds, categories, , , scope] = limit.split(":", 5); const retryAfter = (parseInt(seconds, 10) || 60) * 1000; if (!categories) { this.rateLimits.all = now + retryAfter; } else { for (const category of categories.split(";")) { if (category === "metric_bucket") { if (!scope || scope.split(";").includes("custom")) { this.rateLimits[category] = now + retryAfter; } } else { this.rateLimits[category] = now + retryAfter; } } } } } /** * Get rate limit key for method/URL combination * @param {string} method - HTTP method * @param {string} url - Request URL * @returns {string} Rate limit key */ getRateLimitKey(method, url) { // For now, use a simple approach // Could be enhanced to parse URL and use specific categories return "all"; } /** * Get rate limit time for a specific key * @param {string} key - Rate limit key * @returns {number} Rate limit time */ getRateLimitTime(key) { return this.rateLimits[key] || this.rateLimits.all || 0; } /** * Determine if request should be retried * @param {Error} error - Request error * @param {number} attempt - Current attempt number * @returns {boolean} True if should retry */ shouldRetry(error, attempt) { if (attempt >= this.options.retries) { return false; } if (error.response && error.response.status) { return this.options.retryOn.includes(error.response.status); } // Retry on network errors return ( error.message.includes("Network") || error.message.includes("timeout") ); } /** * Calculate retry delay with exponential backoff * @param {number} attempt - Current attempt number * @param {Error} error - Request error * @returns {number} Delay in milliseconds */ calculateRetryDelay(attempt, error) { // Base delay with exponential backoff let delay = this.options.retryDelay * Math.pow(2, attempt - 1); // Add jitter to prevent thundering herd delay += Math.random() * 1000; // Respect max delay delay = Math.min(delay, this.options.maxRetryDelay); // If we have a retry-after header, respect it if (error.response && error.response.headers["retry-after"]) { const retryAfter = this.parseRetryAfter( error.response.headers["retry-after"], ); delay = Math.max(delay, retryAfter); } return delay; } /** * Sleep for specified milliseconds * @param {number} ms - Milliseconds to sleep * @returns {Promise} Promise that resolves after delay */ sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Extract headers from fetch Response * @param {Headers} headers - Fetch headers object * @returns {Object} Headers object */ extractFetchHeaders(headers) { const result = {}; for (const [key, value] of headers.entries()) { result[key.toLowerCase()] = value; } return result; } /** * Parse fetch response body * @param {Response} response - Fetch response * @returns {Promise} Parsed response body */ async parseFetchResponse(response) { const contentType = response.headers.get("content-type") || ""; if (contentType.includes("application/json")) { return response.json(); } else if (contentType.includes("text/")) { return response.text(); } else { return response.arrayBuffer(); } } /** * Parse XHR headers string * @param {string} headersString - XHR headers string * @returns {Object} Headers object */ parseXhrHeaders(headersString) { const headers = {}; if (!headersString) return headers; const lines = headersString.split("\r\n"); for (const line of lines) { const colonIndex = line.indexOf(":"); if (colonIndex > 0) { const key = line.slice(0, colonIndex).trim().toLowerCase(); const value = line.slice(colonIndex + 1).trim(); headers[key] = value; } } return headers; } /** * Create standardized request error * @param {Error} error - Original error * @param {Object} config - Request config * @param {Object} response - Response object (if available) * @returns {Error} Formatted error */ createRequestError(error, config, response = null) { const requestError = new Error(error.message); requestError.config = config; requestError.response = response; requestError.isRequestError = true; return requestError; } // Convenience methods get(url, config) { return this.request(url, { ...config, method: "GET" }); } post(url, data, config) { return this.request(url, { ...config, method: "POST", data }); } put(url, data, config) { return this.request(url, { ...config, method: "PUT", data }); } delete(url, config) { return this.request(url, { ...config, method: "DELETE" }); } } module.exports = { HttpClient, DEFAULT_RETRY_AFTER, };