-
Notifications
You must be signed in to change notification settings - Fork 341
fix(generator-cli): raise HTTP timeouts for hosted FAI analysis calls #17613
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
base: main
Are you sure you want to change the base?
Changes from all commits
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 |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
| 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; | ||||||||||
| } | ||||||||||
| 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
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. 🔵 suggestion
Comment on lines
+33
to
+38
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. 🟡 CLI transport settings are overwritten When the main CLI invokes Prompt for agentsWas 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
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. 🔵 suggestion If
Suggested change
|
||||||||||
| return await fetch(url, init); | ||||||||||
| } | ||||||||||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
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.
🟡 warning
Proxy detection only checks
HTTP_PROXY. FAI is HTTPS, so a process configured viaHTTPS_PROXY(or lowercasehttps_proxy/http_proxy, which undici'sEnvHttpProxyAgentand most tooling honour) would have its ProxyAgent silently replaced. Widen the guard: