refactor(api): harden relay forwarding with timeout, retries and redacted logs
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user