Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/generator-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
79 changes: 79 additions & 0 deletions packages/generator-cli/src/__test__/faiFetch.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
6 changes: 5 additions & 1 deletion packages/generator-cli/src/pipeline/steps/AutoVersionStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -990,14 +991,17 @@ 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,
language: string,
previousVersion: string
): Promise<FAIAnalysis | null> {
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}`,
Expand Down
53 changes: 53 additions & 0 deletions packages/generator-cli/src/utils/faiFetch.ts
Original file line number Diff line number Diff line change
@@ -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<void> | undefined;

async function installLongRunningDispatcher(): Promise<void> {
if (process.env.HTTP_PROXY != null) {
return;
}
Comment on lines +28 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning

Proxy detection only checks HTTP_PROXY. FAI is HTTPS, so a process configured via HTTPS_PROXY (or lowercase https_proxy/http_proxy, which undici's EnvHttpProxyAgent and most tooling honour) would have its ProxyAgent silently replaced. Widen the guard:

Suggested change
if (process.env.HTTP_PROXY != null) {
return;
}
const proxyEnvVars = ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"];
if (proxyEnvVars.some((name) => process.env[name] != null)) {
return;
}

try {
const { setGlobalDispatcher, Agent } = await import("undici");
setGlobalDispatcher(
new Agent({
headersTimeout: LONG_RUNNING_TIMEOUT_MS,
bodyTimeout: LONG_RUNNING_TIMEOUT_MS
})
);
Comment on lines +33 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion

setGlobalDispatcher with a fresh Agent replaces whatever dispatcher is currently installed, including any non-proxy customization (connect timeouts, custom CA, keep-alive tuning) another part of the process may have set. Worth noting the blast radius in the doc comment, or gating on getGlobalDispatcher() still being the stock default, since this changes timeouts for all fetches in the process, not just FAI.

Comment on lines +33 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 CLI transport settings are overwritten

When the main CLI invokes faiFetch, it replaces the existing dispatcher and restores undici's default connection timeout. Slow connections can now fail.

Prompt for agents
Avoid replacing the process-wide dispatcher inside packages/generator-cli/src/utils/faiFetch.ts. The main Fern CLI installs an Agent with an effectively unlimited connect timeout before PostGenerationPipeline runs, but installLongRunningDispatcher replaces it with an Agent that only customizes header and body timeouts. Use an isolated undici fetch/dispatcher for the hosted FAI request, or preserve the existing dispatcher's transport behavior while extending these timeouts. Also cover execution after the main CLI dispatcher has already been installed.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

} 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<Response> {
dispatcherInstallation ??= installLongRunningDispatcher();
await dispatcherInstallation;
Comment on lines +50 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion

If installLongRunningDispatcher ever rejects (it currently can't, but the try/catch only wraps the import+construction — a future edit could change that), the rejected promise is memoized and every subsequent faiFetch call throws before the request is even attempted. Cheap insurance:

Suggested change
dispatcherInstallation ??= installLongRunningDispatcher();
await dispatcherInstallation;
dispatcherInstallation ??= installLongRunningDispatcher().catch(() => undefined);
await dispatcherInstallation;

return await fetch(url, init);
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading