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
6 changes: 5 additions & 1 deletion packages/fetch/src/fetch.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as path from "node:path";
import { execSync } from "node:child_process";
import { afterEach, describe, expect, test } from "vitest";
import { fetchwithRequestOptions } from "./fetch.js";
import { clearHttpAgentCache } from "./httpAgentCache.js";

// Test server ports
const HTTP_PORT = 3001;
Expand All @@ -15,7 +16,10 @@ const HTTPS_PORT = 3002;
const serversToCleanup: Array<http.Server | https.Server> = [];
const tempDirsToCleanup: string[] = [];

afterEach(() => {
afterEach(async () => {
// Drop keep-alive sockets so the next test does not reuse a closed server.
await clearHttpAgentCache();

// Clean up all servers
serversToCleanup.forEach((server) => server.close());
serversToCleanup.length = 0;
Expand Down
27 changes: 7 additions & 20 deletions packages/fetch/src/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
import { RequestOptions } from "@continuedev/config-types";
import * as followRedirects from "follow-redirects";
import { HttpProxyAgent } from "http-proxy-agent";
import { HttpsProxyAgent } from "https-proxy-agent";
import { BodyInit, RequestInit, Response } from "node-fetch";
import { getAgentOptions } from "./getAgentOptions.js";
import { getOrCreateAgent } from "./httpAgentCache.js";
import patchedFetch from "./node-fetch-patch.js";
import { getProxy, shouldBypassProxy } from "./util.js";

const { http, https } = (followRedirects as any).default;

function logRequest(
method: string,
url: URL,
Expand Down Expand Up @@ -89,22 +84,14 @@ export async function fetchwithRequestOptions(
url.host = "127.0.0.1";
}

const agentOptions = await getAgentOptions(requestOptions);

// Get proxy from options or environment variables
const proxy = getProxy(url.protocol, requestOptions);

// Check if should bypass proxy based on requestOptions or NO_PROXY env var
const shouldBypass = shouldBypassProxy(url.hostname, requestOptions);

// Create agent
const protocol = url.protocol === "https:" ? https : http;
const agent =
proxy && !shouldBypass
? protocol === https
? new HttpsProxyAgent(proxy, agentOptions)
: new HttpProxyAgent(proxy, agentOptions)
: new protocol.Agent(agentOptions);
const agent = await getOrCreateAgent(
url.protocol,
proxy,
shouldBypass,
requestOptions,
);

let headers: { [key: string]: string } = {};

Expand Down
64 changes: 64 additions & 0 deletions packages/fetch/src/httpAgentCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { afterEach, expect, test } from "vitest";
import { clearHttpAgentCache, getOrCreateAgent } from "./httpAgentCache.js";

afterEach(async () => {
await clearHttpAgentCache();
});

test("reuses Agent for identical request options", async () => {
const first = await getOrCreateAgent("https:", undefined, true);
const second = await getOrCreateAgent("https:", undefined, true);
expect(second).toBe(first);
});

test("does not reuse Agent when verifySsl differs", async () => {
const trusted = await getOrCreateAgent("https:", undefined, true, {
verifySsl: true,
});
const insecure = await getOrCreateAgent("https:", undefined, true, {
verifySsl: false,
});
expect(insecure).not.toBe(trusted);
});

test("does not reuse Agent when proxy differs", async () => {
const direct = await getOrCreateAgent("https:", undefined, true);
const proxied = await getOrCreateAgent("https:", "http://127.0.0.1:9", false);
expect(proxied).not.toBe(direct);
});

test("does not reuse Agent when timeout or protocol differs", async () => {
const def = await getOrCreateAgent("https:", undefined, true);
const short = await getOrCreateAgent("https:", undefined, true, {
timeout: 30,
});
const httpAgent = await getOrCreateAgent("http:", undefined, true);
expect(short).not.toBe(def);
expect(httpAgent).not.toBe(def);
});

test("treats bypassed proxy as the same as no proxy", async () => {
const direct = await getOrCreateAgent("https:", undefined, true);
const bypassed = await getOrCreateAgent("https:", "http://127.0.0.1:9", true);
expect(bypassed).toBe(direct);
});

test("created Agent has keepAlive enabled", async () => {
const agent = await getOrCreateAgent("https:", undefined, true);
expect((agent as { keepAlive?: boolean }).keepAlive).toBe(true);
});

test("clearHttpAgentCache drops the cached instance", async () => {
const first = await getOrCreateAgent("https:", undefined, true);
await clearHttpAgentCache();
const second = await getOrCreateAgent("https:", undefined, true);
expect(second).not.toBe(first);
});

test("concurrent first requests share one Agent", async () => {
const [a, b] = await Promise.all([
getOrCreateAgent("https:", undefined, true),
getOrCreateAgent("https:", undefined, true),
]);
expect(a).toBe(b);
});
84 changes: 84 additions & 0 deletions packages/fetch/src/httpAgentCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { RequestOptions } from "@continuedev/config-types";
import * as followRedirects from "follow-redirects";
import { HttpProxyAgent } from "http-proxy-agent";
import { HttpsProxyAgent } from "https-proxy-agent";
import { getAgentOptions } from "./getAgentOptions.js";

const { http, https } = (followRedirects as any).default;

export function agentCacheKey(
protocol: string,
proxy: string | undefined,
shouldBypass: boolean,
requestOptions?: RequestOptions,
): string {
const ca = requestOptions?.caBundlePath;
const caPart = Array.isArray(ca) ? [...ca].sort().join("|") : (ca ?? "");
const client = requestOptions?.clientCertificate;
return JSON.stringify({
protocol,
proxy: shouldBypass ? "" : (proxy ?? ""),
timeout: requestOptions?.timeout ?? null,
verifySsl: requestOptions?.verifySsl ?? null,
ca: caPart,
cert: client?.cert ?? "",
key: client?.key ?? "",
passphrase: client?.passphrase ?? "",
});
}

const agentCache = new Map<string, Promise<{ destroy?: () => void }>>();

function createAgent(
protocol: string,
proxy: string | undefined,
shouldBypass: boolean,
agentOptions: { [key: string]: any },
) {
const httpModule = protocol === "https:" ? https : http;
if (proxy && !shouldBypass) {
return protocol === "https:"
? new HttpsProxyAgent(proxy, agentOptions)
: new HttpProxyAgent(proxy, agentOptions);
}
return new httpModule.Agent(agentOptions);
}

export async function getOrCreateAgent(
protocol: string,
proxy: string | undefined,
shouldBypass: boolean,
requestOptions?: RequestOptions,
) {
const key = agentCacheKey(protocol, proxy, shouldBypass, requestOptions);
const cached = agentCache.get(key);
if (cached) {
return cached;
}
const pending = (async () => {
const agentOptions = await getAgentOptions(requestOptions);
return createAgent(protocol, proxy, shouldBypass, agentOptions);
})();
agentCache.set(key, pending);
try {
return await pending;
} catch (error) {
if (agentCache.get(key) === pending) {
agentCache.delete(key);
}
throw error;
}
}

export async function clearHttpAgentCache() {
const pending = [...agentCache.values()];
agentCache.clear();
for (const created of pending) {
try {
const agent = await created;
agent.destroy?.();
} catch {
// Creation failed; nothing to destroy.
}
}
}
1 change: 1 addition & 0 deletions packages/fetch/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default defineConfig({
"./util.js": "./util.ts",
"./certs.js": "./certs.ts",
"./fetch.js": "./fetch.ts",
"./httpAgentCache.js": "./httpAgentCache.ts",
},
},
});
Loading