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

400 lines
12 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 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;
};