feat(relay): add policy overrides and idempotency-aware retry gating

This commit is contained in:
2026-03-24 13:03:49 +00:00
parent 1ba0cbe8a9
commit bb190946ae
3 changed files with 263 additions and 13 deletions
+80 -13
View File
@@ -13,6 +13,7 @@ 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;
@@ -29,11 +30,24 @@ const RETRYABLE_ERROR_CODES = new Set([
"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));
};
@@ -65,6 +79,24 @@ const resolveNumericOverride = ({
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({
@@ -93,7 +125,11 @@ const getRelayConfig = () => {
min: 0,
max: MAX_RETRY_DELAY_MS,
allowZero: true
})
}),
allowNonIdempotentRetries: parseBoolean(
process.env.RELAY_ALLOW_NON_IDEMPOTENT_RETRIES,
DEFAULT_ALLOW_NON_IDEMPOTENT_RETRIES
)
};
};
@@ -122,8 +158,15 @@ const getStatusClass = (status) => {
return `${Math.floor(status / 100)}xx`;
};
const shouldRetryRelayError = ({ error, attempt, maxRetries }) => {
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;
@@ -139,12 +182,14 @@ export const relayGet = async ({
res,
errorResponse,
transformData,
requestOptionsBuilder
requestOptionsBuilder,
relayPolicy
}) => {
try {
const { data, accessToken } = await relayGetData({
queryUrl,
requestOptionsBuilder
requestOptionsBuilder,
relayPolicy
});
return respondSuccess(
@@ -164,12 +209,14 @@ export const relayGet = async ({
export const relayGetData = async ({
queryUrl,
requestOptionsBuilder,
accessToken
accessToken,
relayPolicy
}) => {
return forwardGetData({
queryUrl,
requestOptionsBuilder,
accessToken
accessToken,
relayPolicy
});
};
@@ -182,19 +229,26 @@ export const forwardGetData = async ({
timeoutMs,
maxRetries,
retryBaseDelayMs,
retryMaxDelayMs
retryMaxDelayMs,
method,
relayPolicy
}) => {
const relayConfig = getRelayConfig();
const relayPolicyConfig = relayPolicy || {};
const resolvedMethod = resolveRelayMethod(
relayPolicyConfig.method || method
);
const resolvedTimeoutMs = resolveNumericOverride({
overrideValue: timeoutMs,
overrideValue: relayPolicyConfig.timeoutMs ?? timeoutMs,
fallbackValue: relayConfig.timeoutMs,
min: 100,
max: MAX_TIMEOUT_MS
});
const resolvedMaxRetries = resolveNumericOverride({
overrideValue: maxRetries,
overrideValue: relayPolicyConfig.maxRetries ?? maxRetries,
fallbackValue: relayConfig.maxRetries,
min: 0,
max: MAX_RETRIES,
@@ -202,7 +256,7 @@ export const forwardGetData = async ({
});
const resolvedRetryBaseDelayMs = resolveNumericOverride({
overrideValue: retryBaseDelayMs,
overrideValue: relayPolicyConfig.retryBaseDelayMs ?? retryBaseDelayMs,
fallbackValue: relayConfig.retryBaseDelayMs,
min: 0,
max: MAX_RETRY_DELAY_MS,
@@ -210,13 +264,20 @@ export const forwardGetData = async ({
});
const resolvedRetryMaxDelayMs = resolveNumericOverride({
overrideValue: retryMaxDelayMs,
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
@@ -246,10 +307,12 @@ export const forwardGetData = async ({
relayRequestId,
queryUrl,
baseUrl: resolvedBaseUrl,
method: resolvedMethod,
timeoutMs: resolvedTimeoutMs,
maxRetries: resolvedMaxRetries,
retryBaseDelayMs: resolvedRetryBaseDelayMs,
retryMaxDelayMs: resolvedRetryMaxDelayMs
retryMaxDelayMs: resolvedRetryMaxDelayMs,
allowNonIdempotentRetries: resolvedAllowNonIdempotentRetries
});
let lastError;
@@ -279,7 +342,9 @@ export const forwardGetData = async ({
const retryEligible = shouldRetryRelayError({
error,
attempt,
maxRetries: resolvedMaxRetries
maxRetries: resolvedMaxRetries,
method: resolvedMethod,
allowNonIdempotentRetries: resolvedAllowNonIdempotentRetries
});
if (!retryEligible) {
@@ -288,6 +353,7 @@ export const forwardGetData = async ({
relayRequestId,
queryUrl,
baseUrl: resolvedBaseUrl,
method: resolvedMethod,
attempt: attempt + 1,
maxAttempts: resolvedMaxRetries + 1,
attemptsMade: attempt + 1,
@@ -311,6 +377,7 @@ export const forwardGetData = async ({
relayRequestId,
queryUrl,
baseUrl: resolvedBaseUrl,
method: resolvedMethod,
attempt: attempt + 1,
maxAttempts: resolvedMaxRetries + 1,
attemptsMade: attempt + 1,