Repository navigation
Honor tools.startup-timeout in MCP gateway health checks #69372
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3425704
6ca2e41
5bf3a98
d83bc8b
5b71478
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -83,8 +83,10 @@ async function retryGatewayStartup(probe, retryConfig, deadline, label, shouldRe | |
| throw err; | ||
| } | ||
| delayMs = Math.min(delayMs * retryConfig.backoffMultiplier, retryConfig.maxDelayMs); | ||
| const sleepMs = Math.min(delayMs, deadline - nowMs()); | ||
| core.warning(`${label}: retrying after attempt ${attempt} in ${sleepMs}ms`); | ||
| const sleepMs = Math.min(Math.ceil(delayMs), deadline - nowMs()); | ||
| if (attempt === 1 || (attempt & (attempt - 1)) === 0) { | ||
| core.warning(`${label}: retrying after attempt ${attempt} in ${sleepMs}ms`); | ||
| } | ||
| await sleep(sleepMs); | ||
| } | ||
| } | ||
|
|
@@ -114,7 +116,7 @@ const MCP_GATEWAY_HEALTH_RETRY_ENV = Object.freeze({ | |
| /** Default MCP backend startup timeout the retry budget must outlast. */ | ||
| const MCP_GATEWAY_BACKEND_STARTUP_TIMEOUT_DEFAULT_MS = 120_000; | ||
|
|
||
| /** Upper bound on configurable health-check attempts, to keep the wait finite. */ | ||
| /** Upper bound on explicit and automatically sized health-check attempts. */ | ||
| const MCP_GATEWAY_HEALTH_MAX_ATTEMPTS_LIMIT = 10_000; | ||
|
|
||
| /** | ||
|
|
@@ -131,38 +133,46 @@ const MCP_GATEWAY_HEALTH_REGISTRATION_MARGIN_MS = 25_000; | |
| * @param {number} defaultValue | ||
| * @param {NodeJS.ProcessEnv} env | ||
| * @param {{ integer?: boolean }} [options] | ||
| * @returns {number} | ||
| * @returns {{ value: number, wasExplicit: boolean }} | ||
| */ | ||
| function readPositiveEnvNumber(envName, defaultValue, env, options = {}) { | ||
| const raw = env[envName]; | ||
| if (raw === undefined || !raw.trim()) { | ||
| return defaultValue; | ||
| return { value: defaultValue, wasExplicit: false }; | ||
| } | ||
| const parsed = Number(raw.trim()); | ||
| const isValid = Number.isFinite(parsed) && parsed > 0 && (!options.integer || Number.isSafeInteger(parsed)); | ||
| if (!isValid) { | ||
| core.warning(`Ignoring invalid ${envName}='${raw}': expected a positive ${options.integer ? "integer" : "number"}, using ${defaultValue}`); | ||
| return defaultValue; | ||
| return { value: defaultValue, wasExplicit: false }; | ||
| } | ||
| return parsed; | ||
| return { value: parsed, wasExplicit: true }; | ||
| } | ||
|
|
||
| /** | ||
| * Computes the cumulative startup retry delay before exhausting its attempts. | ||
| * Computes a lower bound on the cumulative startup retry delay before exhausting | ||
| * its attempts. Actual sleeps round up to avoid Node truncating fractional delays. | ||
| * The retry loop applies the backoff multiplier before its first sleep, | ||
| * so the first retry waits initialDelayMs * backoffMultiplier. | ||
| * | ||
| * @param {{ maxTotalAttempts: number, initialDelayMs: number, maxDelayMs: number, backoffMultiplier: number }} config | ||
| * @returns {number} | ||
| */ | ||
| function computeHealthRetryBudgetMs(config) { | ||
| let delayMs = config.initialDelayMs; | ||
| let budgetMs = 0; | ||
| for (let retry = 1; retry < config.maxTotalAttempts; retry += 1) { | ||
| delayMs = Math.min(delayMs * config.backoffMultiplier, config.maxDelayMs); | ||
| budgetMs += delayMs; | ||
| const retries = config.maxTotalAttempts - 1; | ||
| if (retries <= 0) { | ||
| return 0; | ||
| } | ||
| if (config.backoffMultiplier === 1) { | ||
| return Math.min(config.initialDelayMs, config.maxDelayMs) * retries; | ||
| } | ||
| return budgetMs; | ||
|
|
||
| // Sum the exponential ramp and capped tail without iterating over potentially | ||
| // millions of attempts. expm1 preserves precision for multipliers close to 1. | ||
| const logMultiplier = Math.log(config.backoffMultiplier); | ||
| const rampRetries = Math.min(retries, Math.max(0, Math.ceil(Math.log(config.maxDelayMs / config.initialDelayMs) / logMultiplier) - 1)); | ||
| const rampBudgetMs = rampRetries === 0 ? 0 : (config.initialDelayMs * config.backoffMultiplier * Math.expm1(rampRetries * logMultiplier)) / (config.backoffMultiplier - 1); | ||
| return rampBudgetMs + (retries - rampRetries) * config.maxDelayMs; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -173,11 +183,13 @@ function computeHealthRetryBudgetMs(config) { | |
| * @returns {{ maxTotalAttempts: number, initialDelayMs: number, maxDelayMs: number, backoffMultiplier: number, backendStartupTimeoutMs: number, retryBudgetMs: number }} | ||
| */ | ||
| function resolveGatewayHealthRetryConfig(env = process.env) { | ||
| let maxTotalAttempts = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.maxTotalAttempts, MCP_GATEWAY_HEALTH_RETRY_DEFAULTS.maxTotalAttempts, env, { integer: true }); | ||
| const initialDelayMs = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.initialDelayMs, MCP_GATEWAY_HEALTH_RETRY_DEFAULTS.initialDelayMs, env, { integer: true }); | ||
| let maxDelayMs = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.maxDelayMs, MCP_GATEWAY_HEALTH_RETRY_DEFAULTS.maxDelayMs, env, { integer: true }); | ||
| let backoffMultiplier = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.backoffMultiplier, MCP_GATEWAY_HEALTH_RETRY_DEFAULTS.backoffMultiplier, env); | ||
| const backendStartupTimeoutMs = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.backendStartupTimeoutMs, MCP_GATEWAY_BACKEND_STARTUP_TIMEOUT_DEFAULT_MS, env, { integer: true }); | ||
| const maxAttemptsOverride = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.maxTotalAttempts, MCP_GATEWAY_HEALTH_RETRY_DEFAULTS.maxTotalAttempts, env, { integer: true }); | ||
| let maxTotalAttempts = maxAttemptsOverride.value; | ||
| const hasExplicitAttempts = maxAttemptsOverride.wasExplicit; | ||
| const initialDelayMs = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.initialDelayMs, MCP_GATEWAY_HEALTH_RETRY_DEFAULTS.initialDelayMs, env, { integer: true }).value; | ||
| let maxDelayMs = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.maxDelayMs, MCP_GATEWAY_HEALTH_RETRY_DEFAULTS.maxDelayMs, env, { integer: true }).value; | ||
| let backoffMultiplier = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.backoffMultiplier, MCP_GATEWAY_HEALTH_RETRY_DEFAULTS.backoffMultiplier, env).value; | ||
| const backendStartupTimeoutMs = readPositiveEnvNumber(MCP_GATEWAY_HEALTH_RETRY_ENV.backendStartupTimeoutMs, MCP_GATEWAY_BACKEND_STARTUP_TIMEOUT_DEFAULT_MS, env, { integer: true }).value; | ||
|
|
||
| if (maxTotalAttempts > MCP_GATEWAY_HEALTH_MAX_ATTEMPTS_LIMIT) { | ||
| core.warning(`${MCP_GATEWAY_HEALTH_RETRY_ENV.maxTotalAttempts}=${maxTotalAttempts} exceeds the supported maximum, using ${MCP_GATEWAY_HEALTH_MAX_ATTEMPTS_LIMIT}`); | ||
|
|
@@ -199,9 +211,25 @@ function resolveGatewayHealthRetryConfig(env = process.env) { | |
| } | ||
|
|
||
| const config = { maxTotalAttempts, initialDelayMs, maxDelayMs, backoffMultiplier, backendStartupTimeoutMs }; | ||
| const retryBudgetMs = computeHealthRetryBudgetMs(config); | ||
|
|
||
| const requiredBudgetMs = backendStartupTimeoutMs + MCP_GATEWAY_HEALTH_REGISTRATION_MARGIN_MS; | ||
| let retryBudgetMs = computeHealthRetryBudgetMs(config); | ||
| if (!hasExplicitAttempts && retryBudgetMs < requiredBudgetMs) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removing the 10k ceiling for auto-sized retries can turn a bad timeout/delay config into hundreds of thousands or even billions of 1ms health probes, which floods Actions logs and hammers the gateway instead of failing predictably. 💡 Why this blocks
Please keep automatic sizing bounded as well (or enforce a minimum polling interval / stop warning on every retry) so the retry budget cannot explode under user-provided timeout settings.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Automatic retries now share the 10,000-attempt cap, warn when that cap leaves the budget short, and emit retry warnings at exponentially spaced attempts. Regression coverage and documentation are in |
||
| const firstDelayMs = Math.min(initialDelayMs * backoffMultiplier, maxDelayMs); | ||
| let lower = maxTotalAttempts; | ||
| // Bound automatic sizing to prevent very frequent polling from exploding. | ||
| let upper = MCP_GATEWAY_HEALTH_MAX_ATTEMPTS_LIMIT; | ||
| while (lower < upper) { | ||
| const middle = lower + Math.floor((upper - lower) / 2); | ||
| if (computeHealthRetryBudgetMs({ ...config, maxTotalAttempts: middle }) < requiredBudgetMs) { | ||
| lower = middle + 1; | ||
| } else { | ||
| upper = middle; | ||
| } | ||
| } | ||
| config.maxTotalAttempts = lower; | ||
| retryBudgetMs = computeHealthRetryBudgetMs(config); | ||
| } | ||
|
|
||
| if (retryBudgetMs < requiredBudgetMs) { | ||
| core.warning( | ||
| `MCP gateway health retry budget is ${Math.round(retryBudgetMs / 1000)}s, which is below the ${Math.round(requiredBudgetMs / 1000)}s needed for the ${Math.round(backendStartupTimeoutMs / 1000)}s backend startup timeout plus cleanup and registration; the health check may give up before the gateway is ready` | ||
|
|
@@ -1113,7 +1141,8 @@ async function main() { | |
| }, | ||
| healthRetryConfig, | ||
| startupDeadline, | ||
| "MCP gateway health check" | ||
| "MCP gateway health check", | ||
| () => isProcessAlive(gatewayPid) | ||
| ); | ||
| succeeded = true; | ||
| } catch { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added a gateway liveness predicate to the health retry call, so an exited process stops polling and reaches the existing failure diagnostics. Regression coverage is included in
5b71478.