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:*