refactor(api): harden relay forwarding with timeout, retries and redacted logs

This commit is contained in:
2026-03-24 11:43:40 +00:00
parent 1b7d9da32c
commit fad99c93bc
3 changed files with 362 additions and 9 deletions
+154 -9
View File
@@ -1,6 +1,6 @@
import axios from "axios";
import { azureHeaders } from "../../../actions/core/headers";
import { consoleLogger } from "../../../actions/core/logger";
import { consoleLogger, redactSensitive } from "../../../actions/core/logger";
import { getToken } from "../../../actions/core/token";
import { hashAPIPath } from "../../../actions/core/hash";
import { respondError, respondSuccess } from "./apiResponse";
@@ -9,6 +9,69 @@ 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 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 getRelayConfig = () => {
return {
timeoutMs: parsePositiveInt(
process.env.RELAY_TIMEOUT_MS,
DEFAULT_TIMEOUT_MS
),
maxRetries: parsePositiveInt(
process.env.RELAY_RETRY_MAX,
DEFAULT_MAX_RETRIES
),
retryBaseDelayMs: parsePositiveInt(
process.env.RELAY_RETRY_BASE_DELAY_MS,
DEFAULT_RETRY_BASE_DELAY_MS
),
retryMaxDelayMs: parsePositiveInt(
process.env.RELAY_RETRY_MAX_DELAY_MS,
DEFAULT_RETRY_MAX_DELAY_MS
)
};
};
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 (RETRYABLE_STATUS_CODES.has(status)) return true;
const code = error?.code;
return RETRYABLE_ERROR_CODES.has(code);
};
export const relayGet = async ({
queryUrl,
res,
@@ -51,8 +114,34 @@ export const forwardGetData = async ({
queryUrl,
requestOptionsBuilder,
accessToken,
appendHash = true
appendHash = true,
timeoutMs,
maxRetries,
retryBaseDelayMs,
retryMaxDelayMs
}) => {
const relayConfig = getRelayConfig();
const resolvedTimeoutMs =
typeof timeoutMs === "number" && timeoutMs >= 0
? timeoutMs
: relayConfig.timeoutMs;
const resolvedMaxRetries =
typeof maxRetries === "number" && maxRetries >= 0
? maxRetries
: relayConfig.maxRetries;
const resolvedRetryBaseDelayMs =
typeof retryBaseDelayMs === "number" && retryBaseDelayMs >= 0
? retryBaseDelayMs
: relayConfig.retryBaseDelayMs;
const resolvedRetryMaxDelayMs =
typeof retryMaxDelayMs === "number" && retryMaxDelayMs >= 0
? retryMaxDelayMs
: relayConfig.retryMaxDelayMs;
const tokenAccessToken =
typeof accessToken === "string" && accessToken.length > 0
? accessToken
@@ -62,15 +151,71 @@ export const forwardGetData = async ({
const finalUrl =
resolvedBaseUrl + queryUrl + (appendHash ? hashAPIPath(queryUrl) : "");
const { data } = await axios.get(
finalUrl,
const requestOptions =
typeof requestOptionsBuilder === "function"
? requestOptionsBuilder(tokenAccessToken)
: azureHeaders(tokenAccessToken)
);
: azureHeaders(tokenAccessToken);
return {
data,
accessToken: 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) {
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;
};