Files
pedwfrontend/pages/api/middleware/relayForwarding.js
T

333 lines
9.4 KiB
JavaScript

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 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 }) => {
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
};
const relayRequestId = buildRelayRequestId();
const startedAtMs = Date.now();
structuredRelayLog("relay_request_started", {
relayRequestId,
queryUrl,
baseUrl: resolvedBaseUrl,
timeoutMs: resolvedTimeoutMs,
maxRetries: resolvedMaxRetries,
retryBaseDelayMs: resolvedRetryBaseDelayMs,
retryMaxDelayMs: resolvedRetryMaxDelayMs
});
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
});
if (!retryEligible) {
error.__relayAlreadyLogged = true;
structuredRelayLog("relay_request_failed", {
relayRequestId,
queryUrl,
baseUrl: resolvedBaseUrl,
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,
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;
};