zephyr-agent
Version:
Zephyr plugin agent
214 lines (213 loc) • 8.52 kB
JavaScript
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