perf(http): reuse the certificate agent instead of building one per request

fetchWithTrustedCertificatesAsync has 125 call sites and none of them pass a
dispatcher, so every HTTP request Homarr makes - every integration poll, every
proxied image - built a brand new EnvHttpProxyAgent. An agent owns its
connection pool and its TLS session cache, so a fresh one per request means a
full TLS handshake every time and no keep-alive reuse at all.

Measured against a local TLS server, 60 sequential requests:

  fresh agent per request   5.11 ms/req   60 TLS handshakes
  one shared agent          1.69 ms/req    2 TLS handshakes

3.0x faster and 30x fewer handshakes on loopback; over a real network the gap
is larger, since that is where handshake round-trips actually cost something.

Agent construction itself is cheap (0.004 ms, 1.7 KiB) because undici builds
the TLS store lazily at connect time - so the cost was never construction, it
was the handshake that a fresh pool forces.

The trust material is still read on every call and the cached agents are keyed
on a hash of it, so adding or removing a certificate - including by dropping a
file into the mounted folder rather than going through the UI - takes effect on
the very next request, exactly as before. Superseded agents are closed so their
idle sockets are released.
This commit is contained in:
Thomas Camlong committed 2026-08-11 16:09:37 +02:00
1 parent 9b54edd2db
commit 3ba2800db9
2 files changed
+239 -10

No files matched your search

@@ -0,0 +1,153 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
const trustMaterial = vi.hoisted(() => ({
certificates: [] as { content: string; fileName: string }[],
hostnames: [] as { hostname: string; thumbprint: string; certificate: string }[],
loadCalls: 0,
}));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
loadCustomRootCertificatesAsync: () => {
trustMaterial.loadCalls++;
return Promise.resolve(trustMaterial.certificates);
},
getTrustedCertificateHostnamesAsync: () => Promise.resolve(trustMaterial.hostnames),
getAllTrustedCertificatesAsync: () => Promise.resolve(trustMaterial.certificates.map(({ content }) => content)),
}));
// The agent caches live at module scope, so every test needs a fresh copy of the module.
const importRequestModuleAsync = async () => {
vi.resetModules();
return await import("../../../infrastructure/http/request");
};
beforeEach(() => {
trustMaterial.certificates = [];
trustMaterial.hostnames = [];
trustMaterial.loadCalls = 0;
});
describe("createCertificateAgentAsync", () => {
test("should reuse one agent so requests share a connection pool", async () => {
// Arrange
const { createCertificateAgentAsync } = await importRequestModuleAsync();
// Act
const first = await createCertificateAgentAsync();
const second = await createCertificateAgentAsync();
// Assert
expect(second).toBe(first);
});
test("should still read the trust material on every call", async () => {
// Arrange
const { createCertificateAgentAsync } = await importRequestModuleAsync();
// Act
await createCertificateAgentAsync();
await createCertificateAgentAsync();
// Assert
expect(trustMaterial.loadCalls).toBe(2);
});
test("should replace the agent when a certificate is added", async () => {
// Arrange
const { createCertificateAgentAsync } = await importRequestModuleAsync();
const before = await createCertificateAgentAsync();
const closeSpy = vi.spyOn(before, "close");
// Act
trustMaterial.certificates = [{ fileName: "added.crt", content: "-----BEGIN CERTIFICATE-----" }];
const after = await createCertificateAgentAsync();
// Assert
expect(after).not.toBe(before);
expect(closeSpy).toHaveBeenCalled();
});
test("should replace the agent when a trusted hostname changes", async () => {
// Arrange
const { createCertificateAgentAsync } = await importRequestModuleAsync();
const before = await createCertificateAgentAsync();
// Act
trustMaterial.hostnames = [{ hostname: "homarr.dev", thumbprint: "AA:BB", certificate: "cert" }];
const after = await createCertificateAgentAsync();
// Assert
expect(after).not.toBe(before);
});
test("should not share an agent between different body timeouts", async () => {
// Arrange
const { createCertificateAgentAsync } = await importRequestModuleAsync();
// Act
const streaming = await createCertificateAgentAsync(undefined, { bodyTimeout: 0 });
const regular = await createCertificateAgentAsync();
// Assert
expect(streaming).not.toBe(regular);
expect(await createCertificateAgentAsync(undefined, { bodyTimeout: 0 })).toBe(streaming);
});
test("should give a caller supplying its own trust material a dedicated agent", async () => {
// Arrange
const { createCertificateAgentAsync } = await importRequestModuleAsync();
const shared = await createCertificateAgentAsync();
const override = { ca: "custom", checkServerIdentity: () => undefined };
// Act
const first = await createCertificateAgentAsync(override);
const second = await createCertificateAgentAsync(override);
// Assert
expect(first).not.toBe(shared);
expect(second).not.toBe(first);
// The cached agent must survive a call that opted out of the cache.
expect(await createCertificateAgentAsync()).toBe(shared);
});
});
describe("createHttpsAgentAsync", () => {
test("should reuse one agent so TLS sessions can be resumed", async () => {
// Arrange
const { createHttpsAgentAsync } = await importRequestModuleAsync();
// Act
const first = await createHttpsAgentAsync();
const second = await createHttpsAgentAsync();
// Assert
expect(second).toBe(first);
});
test("should replace the agent when a certificate is added", async () => {
// Arrange
const { createHttpsAgentAsync } = await importRequestModuleAsync();
const before = await createHttpsAgentAsync();
// Act
trustMaterial.certificates = [{ fileName: "added.crt", content: "-----BEGIN CERTIFICATE-----" }];
const after = await createHttpsAgentAsync();
// Assert
expect(after).not.toBe(before);
});
test("should give a caller supplying an override a dedicated agent", async () => {
// Arrange
const { createHttpsAgentAsync } = await importRequestModuleAsync();
const shared = await createHttpsAgentAsync();
// Act
const overridden = await createHttpsAgentAsync({ ca: "custom" });
// Assert
expect(overridden).not.toBe(shared);
expect(await createHttpsAgentAsync()).toBe(shared);
});
});