-
Notifications
You must be signed in to change notification settings - Fork 495
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 all 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,8 @@ require("./shim.cjs"); | |
|
|
||
| const fs = require("fs"); | ||
| const path = require("path"); | ||
| const net = require("net"); | ||
| const tls = require("tls"); | ||
| const { withRetry, sleep } = require("./error_recovery.cjs"); | ||
| const { getErrorMessage } = require("./error_helpers.cjs"); | ||
|
|
||
|
|
@@ -33,6 +35,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. | ||
|
|
@@ -366,6 +374,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 | ||
|
|
@@ -407,6 +416,111 @@ 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. | ||
| * | ||
| * For "https:" baseUrls, the probe performs a full TLS handshake (via `tls.connect`) rather | ||
| * than a bare TCP connect, since a raw TCP accept can succeed well before the TLS listener | ||
| * is actually able to negotiate a secure session and serve requests. | ||
| * | ||
| * @param {{ | ||
| * baseUrl: string, | ||
| * timeoutMs?: number, | ||
| * retryDelayMs?: number, | ||
| * perAttemptTimeoutMs?: number, | ||
| * logger?: (msg: string) => void, | ||
| * connectImpl?: (opts: { host: string, port: number }) => import("net").Socket, | ||
| * }} options - `connectImpl`, when provided, overrides the default connect implementation for | ||
| * both http:// and https:// baseUrls (test-only hook). The readiness event awaited is still | ||
| * derived from the baseUrl's protocol: for `https:` baseUrls the returned socket must emit | ||
| * `"secureConnect"` (not `"connect"`) to be treated as ready, matching the real `tls.connect` | ||
| * behavior; for `http:` baseUrls it must emit `"connect"`. | ||
| * @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 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}` }; | ||
| } | ||
| // For https:// providers, a bare TCP accept does not prove the listener can complete a TLS | ||
| // handshake. Probe with tls.connect and wait for "secureConnect" so the readiness gate lines | ||
| // up with the actual failure mode (handshake/startup errors), not just an open port. | ||
| const isHttps = parsed.protocol === "https:"; | ||
| const readyEvent = isHttps ? "secureConnect" : "connect"; | ||
| const connectImpl = options?.connectImpl ?? (isHttps ? opts => tls.connect({ ...opts, servername: opts.host }) : opts => net.connect(opts)); | ||
|
|
||
| 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 remainingBudgetMs = timeoutMs - (Date.now() - startedAt); | ||
| const attemptTimeoutMs = Math.max(1, Math.min(perAttemptTimeoutMs, remainingBudgetMs)); | ||
| 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 ${attemptTimeoutMs}ms`; | ||
| socket.destroy(); | ||
| settle(false); | ||
| }, attemptTimeoutMs); | ||
| const clear = () => clearTimeout(timer); | ||
| socket.once(readyEvent, () => { | ||
| clear(); | ||
| // Remove the error listener before tearing down the socket: a successful handshake has | ||
| // already settled this probe as ready, and a late/trailing error (e.g. an abrupt RST) | ||
| // must not overwrite lastError or otherwise affect the already-settled result. | ||
| socket.removeAllListeners("error"); | ||
| socket.destroy(); | ||
| 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 }; | ||
| } | ||
| const remainingAfterAttemptMs = timeoutMs - (Date.now() - startedAt); | ||
| if (remainingAfterAttemptMs <= 0) { | ||
| break; | ||
| } | ||
| await sleep(Math.min(retryDelayMs, remainingAfterAttemptMs)); | ||
| } | ||
| 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. | ||
|
|
@@ -836,12 +950,16 @@ 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, | ||
| DEFAULT_API_PROXY_HOST_BRIDGE, | ||
| GEMINI_MODEL_NAME_PREFIX, | ||
| enrichReflectModels, | ||
| extractModelIds, | ||
| fetchAWFReflect, | ||
| fetchModelsFromUrl, | ||
| waitForProviderListenerReady, | ||
| getCatalogModelEntry, | ||
| hasAPIProxyLocalhostAlias, | ||
| inferProviderTypeForModel, | ||
|
|
||
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.