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 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 parsePositiveInt = (value, fallback) => { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) && parsed >= 0 ? parsed : 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 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 }) }; }; 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 shouldRetryRelayError = ({ error, attempt, maxRetries }) => { if (attempt >= maxRetries) 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 }) => { try { const { data, accessToken } = await relayGetData({ queryUrl, requestOptionsBuilder }); 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 }) => { return forwardGetData({ queryUrl, requestOptionsBuilder, accessToken }); }; export const forwardGetData = async ({ baseUrl, queryUrl, requestOptionsBuilder, accessToken, appendHash = true, timeoutMs, maxRetries, retryBaseDelayMs, retryMaxDelayMs }) => { const relayConfig = getRelayConfig(); const resolvedTimeoutMs = resolveNumericOverride({ overrideValue: timeoutMs, fallbackValue: relayConfig.timeoutMs, min: 100, max: MAX_TIMEOUT_MS }); const resolvedMaxRetries = resolveNumericOverride({ overrideValue: maxRetries, fallbackValue: relayConfig.maxRetries, min: 0, max: MAX_RETRIES, allowZero: true }); const resolvedRetryBaseDelayMs = resolveNumericOverride({ overrideValue: retryBaseDelayMs, fallbackValue: relayConfig.retryBaseDelayMs, min: 0, max: MAX_RETRY_DELAY_MS, allowZero: true }); const resolvedRetryMaxDelayMs = resolveNumericOverride({ overrideValue: retryMaxDelayMs, fallbackValue: relayConfig.retryMaxDelayMs, min: 0, max: MAX_RETRY_DELAY_MS, allowZero: true }); 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 }; let lastError; for (let attempt = 0; attempt <= resolvedMaxRetries; attempt += 1) { try { const { data } = await axios.get(finalUrl, axiosOptions); return { data, accessToken: tokenAccessToken }; } catch (error) { lastError = error; const retryEligible = shouldRetryRelayError({ error, attempt, maxRetries: resolvedMaxRetries }); if (!retryEligible) { error.__relayAlreadyLogged = true; structuredRelayLog("relay_request_failed", { queryUrl, baseUrl: resolvedBaseUrl, attempt: attempt + 1, maxAttempts: resolvedMaxRetries + 1, status: error?.response?.status, code: error?.code, message: error?.message }); throw error; } const delayMs = backoffDelayMs({ attempt, retryBaseDelayMs: resolvedRetryBaseDelayMs, retryMaxDelayMs: resolvedRetryMaxDelayMs }); structuredRelayLog("relay_request_retrying", { queryUrl, baseUrl: resolvedBaseUrl, attempt: attempt + 1, maxAttempts: resolvedMaxRetries + 1, delayMs, status: error?.response?.status, code: error?.code, message: error?.message }); await wait(delayMs); } } throw lastError; };