import axios from "axios"; import { azureHeaders } from "../../../actions/core/headers"; import { consoleLogger, redactSensitive } from "../../../actions/core/logger"; import { getToken } from "../../../actions/core/token"; import { hashAPIPath } from "../../../actions/core/hash"; import { respondError, respondSuccess } from "./apiResponse"; const WEBAPI_URL = process.env.RELAY_ROOT || "https://dev-pedw-ns.servicebus.windows.net/dev-pedw-hc/"; const DEFAULT_TIMEOUT_MS = 8000; const DEFAULT_MAX_RETRIES = 2; const DEFAULT_RETRY_BASE_DELAY_MS = 200; const DEFAULT_RETRY_MAX_DELAY_MS = 1200; const DEFAULT_ALLOW_NON_IDEMPOTENT_RETRIES = false; const MAX_TIMEOUT_MS = 30000; const MAX_RETRIES = 4; const MAX_RETRY_DELAY_MS = 5000; const NON_RETRYABLE_STATUS_CODES = new Set([400, 401, 403, 404, 422]); const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]); const RETRYABLE_ERROR_CODES = new Set([ "ECONNABORTED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "EPIPE" ]); const IDEMPOTENT_RETRY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); const parsePositiveInt = (value, fallback) => { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; }; const parseBoolean = (value, fallback) => { if (typeof value === "boolean") return value; if (typeof value === "string") { const normalized = value.trim().toLowerCase(); if (normalized === "true") return true; if (normalized === "false") return false; } return fallback; }; const clamp = (value, min, max) => { return Math.min(max, Math.max(min, value)); }; const sanitizeNumberConfig = ({ value, fallback, min, max, allowZero = false }) => { const parsed = parsePositiveInt(value, fallback); const lowerBound = allowZero ? 0 : min; return clamp(parsed, lowerBound, max); }; const resolveNumericOverride = ({ overrideValue, fallbackValue, min, max, allowZero = false }) => { if (typeof overrideValue !== "number" || !Number.isFinite(overrideValue)) { return fallbackValue; } const lowerBound = allowZero ? 0 : min; return clamp(Math.floor(overrideValue), lowerBound, max); }; const resolveBooleanOverride = ({ overrideValue, fallbackValue }) => { if (typeof overrideValue === "boolean") return overrideValue; return fallbackValue; }; const resolveRelayMethod = (value) => { if (typeof value !== "string" || value.trim().length === 0) { return "GET"; } return value.trim().toUpperCase(); }; const canRetryForMethod = ({ method, allowNonIdempotentRetries }) => { if (allowNonIdempotentRetries) return true; return IDEMPOTENT_RETRY_METHODS.has(method); }; const getRelayConfig = () => { return { timeoutMs: sanitizeNumberConfig({ value: process.env.RELAY_TIMEOUT_MS, fallback: DEFAULT_TIMEOUT_MS, min: 100, max: MAX_TIMEOUT_MS }), maxRetries: sanitizeNumberConfig({ value: process.env.RELAY_RETRY_MAX, fallback: DEFAULT_MAX_RETRIES, min: 0, max: MAX_RETRIES, allowZero: true }), retryBaseDelayMs: sanitizeNumberConfig({ value: process.env.RELAY_RETRY_BASE_DELAY_MS, fallback: DEFAULT_RETRY_BASE_DELAY_MS, min: 0, max: MAX_RETRY_DELAY_MS, allowZero: true }), retryMaxDelayMs: sanitizeNumberConfig({ value: process.env.RELAY_RETRY_MAX_DELAY_MS, fallback: DEFAULT_RETRY_MAX_DELAY_MS, min: 0, max: MAX_RETRY_DELAY_MS, allowZero: true }), allowNonIdempotentRetries: parseBoolean( process.env.RELAY_ALLOW_NON_IDEMPOTENT_RETRIES, DEFAULT_ALLOW_NON_IDEMPOTENT_RETRIES ) }; }; const backoffDelayMs = ({ attempt, retryBaseDelayMs, retryMaxDelayMs }) => { return Math.min(retryMaxDelayMs, retryBaseDelayMs * 2 ** attempt); }; const wait = async (delayMs) => { await new Promise((resolve) => setTimeout(resolve, delayMs)); }; const structuredRelayLog = (event, payload) => { console.info(event, redactSensitive(payload)); }; const buildRelayRequestId = () => { return `relay_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; }; const getElapsedMs = (startedAtMs) => { return Math.max(0, Date.now() - startedAtMs); }; const getStatusClass = (status) => { if (typeof status !== "number") return "none"; return `${Math.floor(status / 100)}xx`; }; const shouldRetryRelayError = ({ error, attempt, maxRetries, method, allowNonIdempotentRetries }) => { if (attempt >= maxRetries) return false; if (!canRetryForMethod({ method, allowNonIdempotentRetries })) return false; const status = error?.response?.status; if (NON_RETRYABLE_STATUS_CODES.has(status)) return false; if (RETRYABLE_STATUS_CODES.has(status)) return true; if (typeof status === "number") return false; const code = error?.code; return RETRYABLE_ERROR_CODES.has(code); }; export const relayGet = async ({ queryUrl, res, errorResponse, transformData, requestOptionsBuilder, relayPolicy }) => { try { const { data, accessToken } = await relayGetData({ queryUrl, requestOptionsBuilder, relayPolicy }); return respondSuccess( res, typeof transformData === "function" ? await transformData(data, accessToken) : data ); } catch (error) { if (!error?.__relayAlreadyLogged) { consoleLogger(error); } return respondError(res, errorResponse); } }; export const relayGetData = async ({ queryUrl, requestOptionsBuilder, accessToken, relayPolicy }) => { return forwardGetData({ queryUrl, requestOptionsBuilder, accessToken, relayPolicy }); }; export const forwardGetData = async ({ baseUrl, queryUrl, requestOptionsBuilder, accessToken, appendHash = true, timeoutMs, maxRetries, retryBaseDelayMs, retryMaxDelayMs, method, relayPolicy }) => { const relayConfig = getRelayConfig(); const relayPolicyConfig = relayPolicy || {}; const resolvedMethod = resolveRelayMethod( relayPolicyConfig.method || method ); const resolvedTimeoutMs = resolveNumericOverride({ overrideValue: relayPolicyConfig.timeoutMs ?? timeoutMs, fallbackValue: relayConfig.timeoutMs, min: 100, max: MAX_TIMEOUT_MS }); const resolvedMaxRetries = resolveNumericOverride({ overrideValue: relayPolicyConfig.maxRetries ?? maxRetries, fallbackValue: relayConfig.maxRetries, min: 0, max: MAX_RETRIES, allowZero: true }); const resolvedRetryBaseDelayMs = resolveNumericOverride({ overrideValue: relayPolicyConfig.retryBaseDelayMs ?? retryBaseDelayMs, fallbackValue: relayConfig.retryBaseDelayMs, min: 0, max: MAX_RETRY_DELAY_MS, allowZero: true }); const resolvedRetryMaxDelayMs = resolveNumericOverride({ overrideValue: relayPolicyConfig.retryMaxDelayMs ?? retryMaxDelayMs, fallbackValue: relayConfig.retryMaxDelayMs, min: 0, max: MAX_RETRY_DELAY_MS, allowZero: true }); const resolvedAllowNonIdempotentRetries = resolveBooleanOverride({ overrideValue: relayPolicyConfig.allowNonIdempotentRetries ?? relayPolicyConfig.allowRetriesForNonIdempotent, fallbackValue: relayConfig.allowNonIdempotentRetries }); const tokenAccessToken = typeof accessToken === "string" && accessToken.length > 0 ? accessToken : (await getToken()).access_token; const resolvedBaseUrl = baseUrl || WEBAPI_URL; const finalUrl = resolvedBaseUrl + queryUrl + (appendHash ? hashAPIPath(queryUrl) : ""); const requestOptions = typeof requestOptionsBuilder === "function" ? requestOptionsBuilder(tokenAccessToken) : azureHeaders(tokenAccessToken); const axiosOptions = { ...(requestOptions || {}), timeout: requestOptions?.timeout != null ? requestOptions.timeout : resolvedTimeoutMs }; const relayRequestId = buildRelayRequestId(); const startedAtMs = Date.now(); structuredRelayLog("relay_request_started", { relayRequestId, queryUrl, baseUrl: resolvedBaseUrl, method: resolvedMethod, timeoutMs: resolvedTimeoutMs, maxRetries: resolvedMaxRetries, retryBaseDelayMs: resolvedRetryBaseDelayMs, retryMaxDelayMs: resolvedRetryMaxDelayMs, allowNonIdempotentRetries: resolvedAllowNonIdempotentRetries }); let lastError; for (let attempt = 0; attempt <= resolvedMaxRetries; attempt += 1) { try { const { data, status } = await axios.get(finalUrl, axiosOptions); structuredRelayLog("relay_request_succeeded", { relayRequestId, queryUrl, baseUrl: resolvedBaseUrl, status, statusClass: getStatusClass(status), attemptsMade: attempt + 1, retryCountUsed: attempt, elapsedMs: getElapsedMs(startedAtMs) }); return { data, accessToken: tokenAccessToken }; } catch (error) { lastError = error; const retryEligible = shouldRetryRelayError({ error, attempt, maxRetries: resolvedMaxRetries, method: resolvedMethod, allowNonIdempotentRetries: resolvedAllowNonIdempotentRetries }); if (!retryEligible) { error.__relayAlreadyLogged = true; structuredRelayLog("relay_request_failed", { relayRequestId, queryUrl, baseUrl: resolvedBaseUrl, method: resolvedMethod, attempt: attempt + 1, maxAttempts: resolvedMaxRetries + 1, attemptsMade: attempt + 1, retryCountUsed: attempt, status: error?.response?.status, statusClass: getStatusClass(error?.response?.status), code: error?.code, message: error?.message, elapsedMs: getElapsedMs(startedAtMs) }); throw error; } const delayMs = backoffDelayMs({ attempt, retryBaseDelayMs: resolvedRetryBaseDelayMs, retryMaxDelayMs: resolvedRetryMaxDelayMs }); structuredRelayLog("relay_request_retrying", { relayRequestId, queryUrl, baseUrl: resolvedBaseUrl, method: resolvedMethod, attempt: attempt + 1, maxAttempts: resolvedMaxRetries + 1, attemptsMade: attempt + 1, retryCountUsed: attempt, remainingRetries: resolvedMaxRetries - attempt, delayMs, status: error?.response?.status, statusClass: getStatusClass(error?.response?.status), code: error?.code, message: error?.message, elapsedMs: getElapsedMs(startedAtMs) }); await wait(delayMs); } } throw lastError; };