From fb42acbcd96012da112b68222248f84e74b86f2f Mon Sep 17 00:00:00 2001 From: willkendall01 Date: Tue, 1 Sep 2026 16:08:46 -0400 Subject: [PATCH] fix(generator-cli): raise HTTP timeouts for hosted FAI analysis calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AutoVersionStep calls the hosted FAI service at `/sdks/analyze-commit-diff` for remote generation. FAI chunks a multi-MB SDK diff and fans it out over dozens of sequential LLM calls, so one request legitimately runs for minutes — longer than undici's default 300s headers timeout on Node's global fetch. Routes that call through a `faiFetch` helper that raises undici's header and body timeouts to 960s, just above the FAI load balancer's own idle timeout, so the server's timeout wins the race and the caller gets an HTTP status it can log rather than an opaque client-side abort. undici only honours a per-request `dispatcher` when called through its own `fetch` export — Node's global fetch silently ignores the option — so the helper installs a process-wide dispatcher once, mirroring what the CLI already does at startup in `packages/cli/cli/src/cli.ts`. An `HTTP_PROXY` dispatcher, if the process installed one, is left alone so the proxy is not dropped. Co-Authored-By: Claude --- packages/generator-cli/package.json | 3 +- .../src/__test__/faiFetch.test.ts | 79 +++++++++++++++++++ .../src/pipeline/steps/AutoVersionStep.ts | 6 +- packages/generator-cli/src/utils/faiFetch.ts | 53 +++++++++++++ pnpm-lock.yaml | 3 + 5 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 packages/generator-cli/src/__test__/faiFetch.test.ts create mode 100644 packages/generator-cli/src/utils/faiFetch.ts diff --git a/packages/generator-cli/package.json b/packages/generator-cli/package.json index 2136fc4d5462..716425447972 100644 --- a/packages/generator-cli/package.json +++ b/packages/generator-cli/package.json @@ -52,7 +52,8 @@ "@octokit/rest": "catalog:", "es-toolkit": "catalog:", "semver": "^7.6.3", - "tmp-promise": "catalog:" + "tmp-promise": "catalog:", + "undici": "catalog:" }, "scripts": { "clean": "rm -rf ./lib && rm -rf ./dist", diff --git a/packages/generator-cli/src/__test__/faiFetch.test.ts b/packages/generator-cli/src/__test__/faiFetch.test.ts new file mode 100644 index 000000000000..4fd715f9a1d0 --- /dev/null +++ b/packages/generator-cli/src/__test__/faiFetch.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ setGlobalDispatcher: vi.fn(), Agent: vi.fn() })); + +vi.mock("undici", () => ({ setGlobalDispatcher: mocks.setGlobalDispatcher, Agent: mocks.Agent })); + +async function importFaiFetch() { + // The dispatcher is installed once per process, so each case needs a fresh module. + vi.resetModules(); + return (await import("../utils/faiFetch.js")).faiFetch; +} + +describe("faiFetch", () => { + const fetchMock = vi.fn(); + const originalProxy = process.env.HTTP_PROXY; + + beforeEach(() => { + mocks.setGlobalDispatcher.mockReset(); + mocks.Agent.mockReset(); + fetchMock.mockReset(); + fetchMock.mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + delete process.env.HTTP_PROXY; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + if (originalProxy == null) { + delete process.env.HTTP_PROXY; + } else { + process.env.HTTP_PROXY = originalProxy; + } + }); + + it("passes the request through to fetch and returns its response", async () => { + const faiFetch = await importFaiFetch(); + const init = { method: "POST", body: "{}" }; + + const response = await faiFetch("https://fai.buildwithfern.com/sdks/analyze-commit-diff", init); + + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledExactlyOnceWith( + "https://fai.buildwithfern.com/sdks/analyze-commit-diff", + init + ); + }); + + it("raises the undici header and body timeouts past the load balancer's idle timeout", async () => { + const faiFetch = await importFaiFetch(); + + await faiFetch("https://fai.buildwithfern.com/sdks/analyze-commit-diff", { method: "POST" }); + + expect(mocks.Agent).toHaveBeenCalledTimes(1); + const [options] = mocks.Agent.mock.calls[0] as [{ headersTimeout: number; bodyTimeout: number }]; + expect(options.headersTimeout).toBeGreaterThan(900_000); + expect(options.bodyTimeout).toBeGreaterThan(900_000); + expect(mocks.setGlobalDispatcher).toHaveBeenCalledTimes(1); + }); + + it("installs the dispatcher once no matter how many requests are made", async () => { + const faiFetch = await importFaiFetch(); + + await faiFetch("https://fai.buildwithfern.com/sdks/analyze-commit-diff", { method: "POST" }); + await faiFetch("https://fai.buildwithfern.com/sdks/analyze-commit-diff", { method: "POST" }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(mocks.setGlobalDispatcher).toHaveBeenCalledTimes(1); + }); + + it("leaves the dispatcher alone when the process is proxied, so the proxy is not dropped", async () => { + process.env.HTTP_PROXY = "http://localhost:3128"; + const faiFetch = await importFaiFetch(); + + await faiFetch("https://fai.buildwithfern.com/sdks/analyze-commit-diff", { method: "POST" }); + + expect(mocks.setGlobalDispatcher).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/generator-cli/src/pipeline/steps/AutoVersionStep.ts b/packages/generator-cli/src/pipeline/steps/AutoVersionStep.ts index d3011bf4703b..b36f9761ad97 100644 --- a/packages/generator-cli/src/pipeline/steps/AutoVersionStep.ts +++ b/packages/generator-cli/src/pipeline/steps/AutoVersionStep.ts @@ -20,6 +20,7 @@ import { prependChangelogBlock } from "../../autoversion/index"; import type { PreparedReplay } from "../../replay/replay-run"; +import { faiFetch } from "../../utils/faiFetch"; import type { PipelineLogger } from "../PipelineLogger"; import type { AutoVersionStepConfig, AutoVersionStepResult, PipelineContext } from "../types"; import { BaseStep } from "./BaseStep"; @@ -990,6 +991,9 @@ export class AutoVersionStep extends BaseStep { * Used when no BAML `ai` config is supplied (remote generation via fiddle). FAI * handles chunking, parallelism, and retries server-side. Returns null on * NO_CHANGE; throws on transport/HTTP errors so the caller's PATCH fallback applies. + * + * Goes through `faiFetch` rather than global `fetch`: chunked analysis of a large diff + * runs for minutes, which outlasts undici's default 300s headers timeout. */ private async analyzeViaFaiService( cleanedDiff: string, @@ -997,7 +1001,7 @@ export class AutoVersionStep extends BaseStep { previousVersion: string ): Promise { const baseUrl = this.config.faiBaseUrl ?? "https://fai.buildwithfern.com"; - const response = await fetch(`${baseUrl}/sdks/analyze-commit-diff`, { + const response = await faiFetch(`${baseUrl}/sdks/analyze-commit-diff`, { method: "POST", headers: { Authorization: `Bearer ${this.config.fernToken}`, diff --git a/packages/generator-cli/src/utils/faiFetch.ts b/packages/generator-cli/src/utils/faiFetch.ts new file mode 100644 index 000000000000..eed9e65c8a29 --- /dev/null +++ b/packages/generator-cli/src/utils/faiFetch.ts @@ -0,0 +1,53 @@ +/** + * `fetch` for the hosted FAI endpoints, which are long-polling by nature: a multi-MB SDK + * diff is fanned out server-side over dozens of sequential LLM calls plus a changelog + * rollup, so one request legitimately runs for minutes. + * + * Node's global `fetch` (undici) gives up after its default 300s headers timeout. That is + * shorter than the FAI load balancer's own idle timeout (900s), so on the slowest diffs the + * client would abort before the server did — and an aborted request is indistinguishable + * from a broken one, which is exactly the signal the caller needs to decide between trusting + * the analysis and falling back to a PATCH bump. + * + * undici only honours a per-request `dispatcher` when called through its own `fetch` export + * (Node's global `fetch` silently ignores the option), so the timeouts are raised by + * installing a process-wide dispatcher instead, mirroring what the CLI does at startup in + * `packages/cli/cli/src/cli.ts`. An `HTTP_PROXY` dispatcher, if the process installed one, + * is left alone: replacing it would silently drop the proxy. + */ + +/** + * Sits above the 900s load balancer idle timeout so the server's timeout wins the race and + * the caller gets an HTTP status it can log, rather than an opaque client-side abort. + */ +const LONG_RUNNING_TIMEOUT_MS = 960_000; + +let dispatcherInstallation: Promise | undefined; + +async function installLongRunningDispatcher(): Promise { + if (process.env.HTTP_PROXY != null) { + return; + } + try { + const { setGlobalDispatcher, Agent } = await import("undici"); + setGlobalDispatcher( + new Agent({ + headersTimeout: LONG_RUNNING_TIMEOUT_MS, + bodyTimeout: LONG_RUNNING_TIMEOUT_MS + }) + ); + } catch { + // undici is unavailable (e.g. a runtime that only exposes global fetch). The request + // still goes out; it just keeps the default 300s ceiling. + } +} + +/** + * `fetch`, with undici's header/body timeouts raised far enough that a slow FAI analysis + * is not mistaken for a failed one. Raises the timeouts once per process. + */ +export async function faiFetch(url: string, init: RequestInit): Promise { + dispatcherInstallation ??= installLongRunningDispatcher(); + await dispatcherInstallation; + return await fetch(url, init); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index faffe67155d2..a1f34cad5a35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8085,6 +8085,9 @@ importers: tmp-promise: specifier: 'catalog:' version: 3.0.3 + undici: + specifier: 'catalog:' + version: 6.27.0 devDependencies: '@fern-api/configs': specifier: workspace:*