Repository navigation
Harden Copilot api-proxy startup: verify listener accept readiness and absorb first-request ECONNREFUSED #52619
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 3 commits
3faa392
763e71a
0b6bf81
12d8441
ddb0b38
7a697e8
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 |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ require("./shim.cjs"); | |
|
|
||
| const fs = require("fs"); | ||
| const path = require("path"); | ||
| const net = require("net"); | ||
| const { withRetry, sleep } = require("./error_recovery.cjs"); | ||
| const { getErrorMessage } = require("./error_helpers.cjs"); | ||
|
|
||
|
|
@@ -33,6 +34,12 @@ const AWF_REFLECT_OUTPUT_PATH = "/tmp/gh-aw/sandbox/firewall/awf-reflect.json"; | |
| const AWF_REFLECT_TIMEOUT_MS = 60000; | ||
| // Milliseconds to wait for each models_url fallback fetch (shorter than the main reflect timeout). | ||
| const AWF_MODELS_URL_TIMEOUT_MS = 3000; | ||
| // Milliseconds to wait for an api-proxy provider listener to accept a real TCP connection. | ||
| const AWF_PROVIDER_LISTENER_READY_TIMEOUT_MS = 15000; | ||
| // Delay between provider-listener readiness probes. | ||
| const AWF_PROVIDER_LISTENER_READY_RETRY_MS = 250; | ||
| // Per-attempt connect timeout while probing provider listener readiness. | ||
| const AWF_PROVIDER_LISTENER_READY_PROBE_TIMEOUT_MS = 2000; | ||
| // Maximum attempts for models_url fallback fetches when the proxy is not yet ready. | ||
| const AWF_MODELS_URL_MAX_ATTEMPTS = 5; | ||
| // Base delay between models_url fallback retries. Uses exponential backoff. | ||
|
|
@@ -323,6 +330,7 @@ async function fetchAWFReflect(options) { | |
| status: res.status, | ||
| }; | ||
| } | ||
|
|
||
| /** @type {any} */ | ||
| const reflectData = await res.json(); | ||
| // Attempt to fill in null models for configured providers by fetching directly | ||
|
|
@@ -364,6 +372,88 @@ async function fetchAWFReflect(options) { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Wait until a provider listener (e.g. http://api-proxy:10002) accepts a real TCP | ||
| * connection, or time out. | ||
| * | ||
| * This guards against startup races where /reflect is available but a per-provider | ||
| * listener has not yet bound/started accepting connections. | ||
| * | ||
| * @param {{ | ||
| * baseUrl: string, | ||
| * timeoutMs?: number, | ||
| * retryDelayMs?: number, | ||
| * perAttemptTimeoutMs?: number, | ||
| * logger?: (msg: string) => void, | ||
| * connectImpl?: typeof net.connect, | ||
| * }} options | ||
| * @returns {Promise<{ ok: true } | { ok: false, reason: "invalid_base_url" | "timeout", error: string }>} | ||
| */ | ||
| async function waitForProviderListenerReady(options) { | ||
| const logger = options?.logger ?? DEFAULT_REFLECT_LOGGER; | ||
| const timeoutMs = options?.timeoutMs ?? AWF_PROVIDER_LISTENER_READY_TIMEOUT_MS; | ||
| const retryDelayMs = options?.retryDelayMs ?? AWF_PROVIDER_LISTENER_READY_RETRY_MS; | ||
| const perAttemptTimeoutMsRaw = options?.perAttemptTimeoutMs ?? AWF_PROVIDER_LISTENER_READY_PROBE_TIMEOUT_MS; | ||
| const perAttemptTimeoutMs = Number.isFinite(perAttemptTimeoutMsRaw) && perAttemptTimeoutMsRaw > 0 ? perAttemptTimeoutMsRaw : AWF_PROVIDER_LISTENER_READY_PROBE_TIMEOUT_MS; | ||
|
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. L396-397: yagni: defensive Number.isFinite/>0 validation on an internal-only perAttemptTimeoutMs option nobody sets from outside this repo. Drop the guard; trust the constant default. |
||
| const connectImpl = options?.connectImpl ?? net.connect; | ||
| const baseUrl = String(options?.baseUrl ?? "").trim(); | ||
| if (!baseUrl) { | ||
| return { ok: false, reason: "invalid_base_url", error: "baseUrl is empty" }; | ||
| } | ||
|
|
||
| let parsed; | ||
| try { | ||
| parsed = new URL(baseUrl); | ||
|
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. [/diagnosing-bugs] 💡 Suggested fixsocket.once("connect", () => {
clear();
socket.removeAllListeners("error");
socket.destroy(); // destroy instead of end — we only need the handshake
settle(true);
});Using @copilot please address this. |
||
| } catch { | ||
| return { ok: false, reason: "invalid_base_url", error: `invalid baseUrl: ${baseUrl}` }; | ||
| } | ||
| const host = parsed.hostname; | ||
| const port = parsed.port ? Number.parseInt(parsed.port, 10) : parsed.protocol === "https:" ? 443 : 80; | ||
| if (!host || !Number.isFinite(port) || port <= 0) { | ||
| return { ok: false, reason: "invalid_base_url", error: `baseUrl missing host/port: ${baseUrl}` }; | ||
| } | ||
|
|
||
| logger(`awf-reflect: waiting for provider listener readiness at ${host}:${port} (timeout=${timeoutMs}ms)`); | ||
| const startedAt = Date.now(); | ||
| let lastError = "connection not ready"; | ||
| while (Date.now() - startedAt < timeoutMs) { | ||
| const ready = await new Promise(resolve => { | ||
| const socket = connectImpl({ host, port }); | ||
| let settled = false; | ||
|
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. [/tdd] The per-attempt connect-timeout branch (where the 💡 Suggested test sketchit("returns timeout when per-attempt timer fires (hung connect)", async () => {
const probingConnect = vi.fn().mockImplementation(() => {
// never fire connect or error
return {
once(event, cb) { return this; },
end() {},
destroy() {},
};
});
const result = await waitForProviderListenerReady({
baseUrl: "(apiproxy/redacted)
timeoutMs: 50,
retryDelayMs: 1,
perAttemptTimeoutMs: 5,
connectImpl: probingConnect,
logger: () => {},
});
expect(result.ok).toBe(false);
expect(result.reason).toBe("timeout");
expect(result.error).toContain("timed out after 5ms");
});This branch sets @copilot please address this. |
||
| const settle = value => { | ||
| if (settled) return; | ||
| settled = true; | ||
| resolve(value); | ||
| }; | ||
| const timer = setTimeout(() => { | ||
| clear(); | ||
| lastError = `connect attempt timed out after ${perAttemptTimeoutMs}ms`; | ||
| socket.destroy(); | ||
| settle(false); | ||
| }, perAttemptTimeoutMs); | ||
|
|
||
| const clear = () => clearTimeout(timer); | ||
| socket.once("connect", () => { | ||
|
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. This readiness check is too weak for 💡 Why this needs to change
A safer approach is to probe at the protocol level: use const socket = parsed.protocol === "https:"
? tls.connect({ host, port, servername: host })
: net.connect({ host, port });That keeps the readiness gate aligned with the actual failure mode instead of only checking that the TCP port opened. |
||
| clear(); | ||
| socket.end(); | ||
| settle(true); | ||
| }); | ||
| socket.once("error", err => { | ||
| clear(); | ||
| lastError = getErrorMessage(err); | ||
| socket.destroy(); | ||
| settle(false); | ||
| }); | ||
| }); | ||
| if (ready) { | ||
| logger(`awf-reflect: provider listener is accepting connections at ${host}:${port}`); | ||
| return { ok: true }; | ||
| } | ||
| await sleep(retryDelayMs); | ||
| } | ||
| logger(`awf-reflect: provider listener readiness timed out for ${host}:${port} (${lastError})`); | ||
| return { ok: false, reason: "timeout", error: lastError }; | ||
| } | ||
|
|
||
| /** | ||
| * Returns true when the model name matches well-known Anthropic naming patterns: | ||
| * "claude-*" prefix, or "-opus", "-haiku", or "-sonnet" as a segment or suffix. | ||
|
|
@@ -793,11 +883,15 @@ if (typeof module !== "undefined" && module.exports) { | |
| AWF_MODELS_URL_MAX_ATTEMPTS, | ||
| AWF_MODELS_URL_RETRY_BASE_MS, | ||
| AWF_MODELS_URL_RETRY_MAX_MS, | ||
| AWF_PROVIDER_LISTENER_READY_TIMEOUT_MS, | ||
| AWF_PROVIDER_LISTENER_READY_RETRY_MS, | ||
| AWF_PROVIDER_LISTENER_READY_PROBE_TIMEOUT_MS, | ||
| GEMINI_MODEL_NAME_PREFIX, | ||
| enrichReflectModels, | ||
| extractModelIds, | ||
| fetchAWFReflect, | ||
| fetchModelsFromUrl, | ||
| waitForProviderListenerReady, | ||
| getCatalogModelEntry, | ||
| inferProviderTypeForModel, | ||
| inferWireApiForModel, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,6 +65,8 @@ const { | |
| AWF_REFLECT_TIMEOUT_MS, | ||
| AWF_MODELS_URL_TIMEOUT_MS, | ||
| GEMINI_MODEL_NAME_PREFIX, | ||
| AWF_PROVIDER_LISTENER_READY_TIMEOUT_MS, | ||
| waitForProviderListenerReady, | ||
| enrichReflectModels, | ||
| extractModelIds, | ||
| fetchAWFReflect, | ||
|
|
@@ -162,6 +164,8 @@ const SDK_SESSION_IDLE_TIMEOUT_PATTERN = /Timeout after \d+ms waiting for sessio | |
| // avoid false positives from any process that logs "Gateway shutdown initiated" | ||
| // as plain text. | ||
| const MCP_GATEWAY_SHUTDOWN_PATTERN = /"message"\s*:\s*"Gateway shutdown initiated"/; | ||
| const CONNECTION_REFUSED_ERROR_PATTERN = /connection refused|ECONNREFUSED/i; | ||
| const FIRST_CONNECTION_REFUSED_RETRY_DELAY_MS = 1000; | ||
|
|
||
| // Pattern to detect null-type tool_call error that poisons conversation history. | ||
| // Matches the Copilot API 400 error: | ||
|
|
@@ -488,6 +492,15 @@ function isMCPGatewayShutdownError(output) { | |
| return MCP_GATEWAY_SHUTDOWN_PATTERN.test(output); | ||
| } | ||
|
|
||
| /** | ||
| * Determine whether output contains a connection-refused signal. | ||
| * @param {string} output | ||
| * @returns {boolean} | ||
| */ | ||
| function isConnectionRefusedError(output) { | ||
| return CONNECTION_REFUSED_ERROR_PATTERN.test(output); | ||
| } | ||
|
|
||
| /** | ||
| * Extract a compact tail preview from combined process output for failure logs. | ||
| * @param {string} output | ||
|
|
@@ -1017,6 +1030,20 @@ async function main() { | |
| } | ||
|
|
||
| log(`copilot-sdk driver mode: multi-provider config resolved (${multiProvider.providers.length} providers, ${multiProvider.models.length} models, model=${resolvedModel})`); | ||
|
|
||
| const uniqueProviderBaseUrls = [...new Set(multiProvider.providers.map(provider => String(provider.baseUrl || "").trim()).filter(Boolean))]; | ||
| for (const providerBaseUrlToProbe of uniqueProviderBaseUrls) { | ||
|
Comment on lines
+1045
to
+1049
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. L1035-1042: yagni: loops over uniqueProviderBaseUrls but in practice this is almost always a single provider; the Set/map/filter/loop machinery for the common one-provider case adds indirection for a rare multi-provider path. A simple for-of over multiProvider.providers with baseUrl dedup inline would read the same in fewer moving parts. |
||
| const readiness = await waitForProviderListenerReady({ | ||
| baseUrl: providerBaseUrlToProbe, | ||
| timeoutMs: AWF_PROVIDER_LISTENER_READY_TIMEOUT_MS, | ||
| logger: log, | ||
| }); | ||
| if (!readiness.ok) { | ||
| emitInfrastructureIncomplete(`api-proxy provider listener was not ready at ${providerBaseUrlToProbe} before first Copilot SDK request (${readiness.error}).`); | ||
| log(`copilot-sdk driver mode: provider listener readiness probe failed for ${providerBaseUrlToProbe}: ${readiness.error}`); | ||
|
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. [/diagnosing-bugs] If 💡 Suggested guardif (uniqueProviderBaseUrls.length === 0) {
log("copilot-sdk driver mode: no provider baseUrls to probe — skipping listener readiness check");
}A debug log here makes it visible in traces that the probe was bypassed, which helps when diagnosing future ECONNREFUSED incidents. @copilot please address this. |
||
| process.exit(1); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Merge SDK env additions into the child process env only when the SDK helper | ||
|
|
@@ -1179,6 +1206,7 @@ async function main() { | |
| const isNullTypeToolCall = isNullTypeToolCallError(result.output); | ||
| const isSDKSessionIdleTimeout = isSDKSessionIdleTimeoutError(result.output); | ||
| const isMCPGatewayShutdown = isMCPGatewayShutdownError(result.output); | ||
| const isConnectionRefused = isConnectionRefusedError(result.output); | ||
| const permissionDeniedCount = countPermissionDeniedIssues(result.output); | ||
| const hasNumerousPermissionDenied = hasNumerousPermissionDeniedIssues(result.output); | ||
| const nonRetryableGuard = detectNonRetryableHarnessGuard(result.output); | ||
|
|
@@ -1215,6 +1243,7 @@ async function main() { | |
| ` isNullTypeToolCallError=${isNullTypeToolCall}` + | ||
| ` isSDKSessionIdleTimeoutError=${isSDKSessionIdleTimeout}` + | ||
| ` isMCPGatewayShutdownError=${isMCPGatewayShutdown}` + | ||
| ` isConnectionRefusedError=${isConnectionRefused}` + | ||
| ` isAuthError=${isAuthErr}` + | ||
| ` isAuthenticationFailedError=${isAuthenticationFailed}` + | ||
| ` permissionDeniedCount=${permissionDeniedCount}` + | ||
|
|
@@ -1363,6 +1392,12 @@ async function main() { | |
| } | ||
| } | ||
|
|
||
| if (attempt === 0 && isConnectionRefused && maxRetries > 0) { | ||
|
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. [/diagnosing-bugs] The ECONNREFUSED retry only fires when 💡 SuggestionAdd a comment explaining the invariant: // The readiness probe ensures the listener is up before attempt 0.
// A ECONNREFUSED here means the provider died in the narrow window
// between the probe and the first request — retry once as a fresh run.
if (attempt === 0 && isConnectionRefused && maxRetries > 0) {Also add a test that verifies @copilot please address this. |
||
| useContinueOnRetry = false; | ||
| log(`attempt ${attempt + 1}: connection refused on first request path — retrying as fresh run with short backoff (${FIRST_CONNECTION_REFUSED_RETRY_DELAY_MS}ms) (attempt ${attempt + 2}/${maxRetries + 1})`); | ||
| return { action: "retry", nextDelayMs: FIRST_CONNECTION_REFUSED_RETRY_DELAY_MS }; | ||
|
Comment on lines
+1406
to
+1409
|
||
| } | ||
|
|
||
| if (isStartupRetryEligible && isStartupNoOutputRetryCandidate(result) && scheduledExit2Retries < MAX_SCHEDULED_EXIT2_RETRIES && attempt < maxRetries) { | ||
| scheduledExit2Retries += 1; | ||
| scheduledExit2RetryAttempted = true; | ||
|
|
@@ -1468,6 +1503,7 @@ if (typeof module !== "undefined" && module.exports) { | |
| AGENTIC_ENGINE_TIMEOUT_PATTERN, | ||
| buildMissingToolPermissionIssuePayload, | ||
| isAuthenticationFailedError, | ||
| isConnectionRefusedError, | ||
| isMCPGatewayShutdownError, | ||
| isSDKSessionIdleTimeoutError, | ||
| startCopilotSDKServer, | ||
|
|
||
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.
L392-455: yagni: hand-rolled retry loop with manual timers/socket state machine reimplements the withRetry(operation, config) helper already imported in this file (used at line 183). withRetry + a plain net.connect promise wrapper would cut this to ~15 lines.