UNPKG

zephyr-agent

Version:
214 lines (213 loc) • 8.52 kB
import "node:module"; import axios from "axios"; import { HttpsProxyAgent } from "https-proxy-agent"; import { isCI } from "ci-info"; import { ZeErrors, ZephyrError } from "../errors/index.mjs"; import { redactString, redactUrl, safeStringifyForLogging, sanitizeForLogging } from "../security/redaction.mjs"; const IPV4_FAMILY = 4; const DEFAULT_HTTP_DEADLINE_MS = 30000; const RETRY_BASE_DELAY_MS = 100; const RETRY_ERROR_CODES = [ 'ETIMEDOUT', 'ECONNABORTED', 'ENETUNREACH', 'ECONNRESET', 'ECONNREFUSED', 'EAI_AGAIN', 'ENOTFOUND', 'EPIPE' ]; const RETRY_SAFE_METHODS = new Set([ 'GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE' ]); const RETRYABLE_STATUS_CODES = new Set([ 408, 425, 429 ]); function hasIdempotencyKey(headers) { if (!headers) return false; if (headers instanceof Headers) return headers.has('idempotency-key'); if (Array.isArray(headers)) return headers.some(([key])=>'idempotency-key' === key.toLowerCase()); return Object.keys(headers).some((key)=>'idempotency-key' === key.toLowerCase()); } function getHttpsProxyUrl() { const envVars = [ 'HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy' ]; for (const envVar of envVars){ const value = process.env[envVar]; if (value) return value; } } function shouldBypassProxy(hostname) { const noProxy = process.env['NO_PROXY'] || process.env['no_proxy']; if (!noProxy) return false; const noProxyList = noProxy.split(',').map((h)=>h.trim().toLowerCase()); const hostLower = hostname.toLowerCase(); return noProxyList.some((noProxyHost)=>{ if ('*' === noProxyHost) return true; if (noProxyHost.startsWith('.')) return hostLower.endsWith(noProxyHost) || hostLower === noProxyHost.slice(1); return hostLower === noProxyHost || hostLower.endsWith(`.${noProxyHost}`); }); } function getHttpsProxyAgent(url) { if (shouldBypassProxy(url.hostname)) return; const httpsProxyUrl = getHttpsProxyUrl(); return httpsProxyUrl ? new HttpsProxyAgent(httpsProxyUrl) : void 0; } function toHttpLikeError(error) { if ('object' == typeof error && null !== error || 'function' == typeof error) return error; return { message: String(error) }; } function isRetryableNetworkError(error) { const candidate = toHttpLikeError(error); if (candidate.response) return false; const message = String(candidate.message ?? '').toLowerCase(); return Boolean(candidate.code && RETRY_ERROR_CODES.includes(candidate.code) || message.includes('network') || message.includes('timeout')); } function isRetryableStatus(status) { return RETRYABLE_STATUS_CODES.has(status) || status >= 500; } async function waitForRetry(retryIndex, deadline, signal, retryAfterMs = 0) { if (signal?.aborted) return false; const remaining = deadline - Date.now(); const delay = Math.min(Math.max(RETRY_BASE_DELAY_MS * 2 ** retryIndex, retryAfterMs), remaining - 1); if (delay <= 0) return false; await new Promise((resolve)=>{ const timer = setTimeout(done, delay); function done() { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); } signal?.addEventListener('abort', done, { once: true }); }); return !signal?.aborted && Date.now() < deadline; } function getRetryAfterMs(headers) { const candidate = headers; const value = candidate?.get?.('retry-after') ?? candidate?.['retry-after']; if (null == value) return 0; const seconds = Number(value); if (Number.isFinite(seconds)) return Math.max(0, 1000 * seconds); const retryDate = Date.parse(String(value)); return Number.isNaN(retryDate) ? 0 : Math.max(0, retryDate - Date.now()); } function stringifyUnknown(value) { if ('string' == typeof value) return value; try { return JSON.stringify(value) ?? String(value); } catch { try { return String(value); } catch { return '[unserializable value]'; } } } function toResponse(response) { const headers = Object.entries(response.headers ?? {}).reduce((acc, [key, value])=>{ if (void 0 !== value) acc[key] = 'string' == typeof value ? value : String(value); return acc; }, {}); return { ok: response.status >= 200 && response.status < 300, status: response.status, headers: new Headers(headers), text: async ()=>stringifyUnknown(response.data), json: async ()=>response.data }; } function normalizeRequestError(error, url, method) { if (error instanceof ZephyrError) return error; const candidate = toHttpLikeError(error); if (candidate.response) return new ZephyrError(ZeErrors.ERR_HTTP_ERROR, { status: candidate.response.status, url: redactUrl(url), content: 'string' == typeof candidate.response.data ? redactString(candidate.response.data) : safeStringifyForLogging(candidate.response.data), method }); if ('ENOTFOUND' === candidate.code) { let failedHostname = url.hostname; if ('string' == typeof candidate.hostname) failedHostname = candidate.hostname; else if ('string' == typeof candidate.cause?.hostname) failedHostname = candidate.cause.hostname; return new ZephyrError(ZeErrors.ERR_HTTP_ERROR, { status: -1, url: redactUrl(url), content: `DNS lookup failed for hostname "${redactString(failedHostname)}" (ENOTFOUND). Verify the hostname's DNS record.`, method }); } if (isRetryableNetworkError(error) || 'ERR_CANCELED' === candidate.code) return new ZephyrError(ZeErrors.ERR_HTTP_ERROR, { status: -1, url: redactUrl(url), content: 'ERR_CANCELED' === candidate.code ? 'Request canceled' : redactString(`Request failed after retries: ${candidate.code ?? candidate.message}`), method }); return new ZephyrError(ZeErrors.ERR_UNKNOWN, { message: 'Unknown error occurred', cause: sanitizeForLogging(error) }); } async function fetchWithRetries(url, options = {}, retries = 3, deadlineMs = DEFAULT_HTTP_DEADLINE_MS) { const method = (options.method ?? 'GET').toUpperCase(); const retrySafe = RETRY_SAFE_METHODS.has(method) || hasIdempotencyKey(options.headers); const maxRetries = retrySafe && Number.isFinite(retries) ? Math.max(0, Math.floor(retries)) : 0; const boundedDeadlineMs = Number.isFinite(deadlineMs) ? Math.max(1, deadlineMs) : DEFAULT_HTTP_DEADLINE_MS; const deadline = Date.now() + boundedDeadlineMs; const httpsAgent = getHttpsProxyAgent(url); const axiosInstance = axios.create({ family: isCI ? IPV4_FAMILY : void 0, httpsAgent, validateStatus: ()=>true }); const axiosConfig = { method, data: options.body, ...options.signal ? { signal: options.signal } : {} }; if (options.headers) if (options.headers instanceof Headers) { const headersRecord = {}; options.headers.forEach((value, key)=>{ headersRecord[key] = value; }); axiosConfig.headers = headersRecord; } else if (Array.isArray(options.headers)) { const headersRecord = {}; options.headers.forEach(([key, value])=>{ headersRecord[key] = value; }); axiosConfig.headers = headersRecord; } else axiosConfig.headers = options.headers; for(let attempt = 0; attempt <= maxRetries; attempt++)try { const response = await axiosInstance(url.toString(), { ...axiosConfig, timeout: Math.max(1, deadline - Date.now()) }); if (attempt < maxRetries && isRetryableStatus(response.status) && await waitForRetry(attempt, deadline, options.signal, getRetryAfterMs(response.headers))) continue; return toResponse(response); } catch (error) { const canRetry = attempt < maxRetries && !options.signal?.aborted && isRetryableNetworkError(error) && await waitForRetry(attempt, deadline, options.signal); if (canRetry) continue; throw normalizeRequestError(error, url, method); } throw new ZephyrError(ZeErrors.ERR_UNKNOWN, { message: 'HTTP retry loop exited unexpectedly' }); } export { DEFAULT_HTTP_DEADLINE_MS, fetchWithRetries }; //# sourceMappingURL=fetch-with-retries.mjs.map