apply proxy agents to http monitors

This commit is contained in:
Alex Holliday committed 2026-08-21 12:19:23 -07:00
1 parent 1152a70afb
commit 252067142e
4 files changed
+216 -8

No files matched your search

+10
View File
@@ -26,6 +26,7 @@
"got": "14.4.7",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
"hpagent": "1.2.0",
"isomorphic-dompurify": "^2.26.0",
"jmespath": "^0.16.0",
"jsdom": "^26.1.0",
@@ -7778,6 +7779,15 @@
"node": ">=18.0.0"
}
},
"node_modules/hpagent": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz",
"integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+1
View File
@@ -49,6 +49,7 @@
"got": "14.4.7",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
"hpagent": "1.2.0",
"isomorphic-dompurify": "^2.26.0",
"jmespath": "^0.16.0",
"jsdom": "^26.1.0",
+56 -8
View File
@@ -9,11 +9,17 @@ import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { isStatusUp } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import CacheableLookup from "cacheable-lookup";
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent";
import { CheckContext } from "@/types/network.js";
export const PROXY_CONNECT_ERROR_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "ENOTFOUND", "EAI_AGAIN", "EPIPE"]);
export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
readonly type = "http";
// Shared, pooled agents reused across every check
private static readonly PROXY_AGENT_CACHE_MAX = 50;
private readonly proxyAgents = new Map<string, { http: HttpProxyAgent; https: HttpsProxyAgent }>();
private readonly httpAgent: HttpAgent;
private readonly httpsAgent: HttpsAgent;
private readonly httpsAgentInsecure: HttpsAgent;
@@ -41,6 +47,44 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
return type === "http";
}
private proxyFailureMessage = (error: RequestError, proxyUrl: string): string | undefined => {
if (error.response || !error.code || !PROXY_CONNECT_ERROR_CODES.has(error.code)) {
return undefined;
}
const { hostname, port } = new URL(proxyUrl);
return `Proxy connection failed (${hostname}:${port}): ${error.message}`;
};
private getProxyAgents(proxyUrl: string, ignoreTlsErrors: boolean) {
const key = `${proxyUrl}|${ignoreTlsErrors}`;
const hit = this.proxyAgents.get(key);
// Agent already exists, refresh
if (hit) {
this.proxyAgents.delete(key); // Refresh
this.proxyAgents.set(key, hit);
return hit;
}
// Create new agents
const agentOptions = { keepAlive: true, maxSockets: 256, maxFreeSockets: 256, proxy: proxyUrl };
const pair = {
http: new HttpProxyAgent(agentOptions),
https: new HttpsProxyAgent({ ...agentOptions, rejectUnauthorized: !ignoreTlsErrors }),
};
// Cache agents
this.proxyAgents.set(key, pair);
if (this.proxyAgents.size > HttpProvider.PROXY_AGENT_CACHE_MAX) {
const [oldestKey] = this.proxyAgents.keys(); // Destructuring gets the first inserted, ie oldest, item
if (oldestKey === undefined) return pair;
const oldest = this.proxyAgents.get(oldestKey);
oldest?.http.destroy();
oldest?.https.destroy();
this.proxyAgents.delete(oldestKey);
}
return pair;
}
private buildResponse<T>(
monitor: Monitor,
opts: {
@@ -113,20 +157,22 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
};
}
private handleHttpError<T>(error: unknown, monitor: Monitor): MonitorStatusResponse<T> {
private handleHttpError<T>(error: unknown, monitor: Monitor, ctx?: CheckContext): MonitorStatusResponse<T> {
if (error instanceof HTTPError || error instanceof RequestError) {
const statusCode = error.response?.statusCode;
const statusUp = isStatusUp(statusCode, monitor.customUpCodes);
const responseTime = error.timings?.phases?.total ?? 0;
if (!statusUp) {
const message = (ctx?.proxyUrl && error instanceof RequestError && this.proxyFailureMessage(error, ctx.proxyUrl)) || error.message;
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: statusCode ?? NETWORK_ERROR,
message: error.message,
message: message,
responseTime,
timings: error.timings,
payload: null as T,
@@ -156,7 +202,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
};
}
async handle<T>(monitor: Monitor): Promise<MonitorStatusResponse<T>> {
async handle<T>(monitor: Monitor, ctx?: CheckContext): Promise<MonitorStatusResponse<T>> {
const { url, secret, ignoreTlsErrors } = monitor;
if (!url) {
@@ -167,10 +213,12 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined,
};
options.agent = {
http: this.httpAgent,
https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent,
};
options.agent = ctx?.proxyUrl
? this.getProxyAgents(ctx.proxyUrl, Boolean(ignoreTlsErrors))
: {
http: this.httpAgent,
https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent,
};
options.method = monitor.method;
@@ -188,7 +236,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
timings: response.timings,
});
} catch (error: unknown) {
return this.handleHttpError(error, monitor);
return this.handleHttpError(error, monitor, ctx);
}
}
}
@@ -1,4 +1,5 @@
import { describe, expect, it, jest } from "@jest/globals";
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
@@ -494,4 +495,152 @@ describe("HttpProvider", () => {
expect(advancedMatcher.validate).not.toHaveBeenCalled();
});
});
// ── Proxy support ────────────────────────────────────────────────────
describe("proxy support", () => {
const PROXY_URL = "http://proxy.example.com:8080";
const gotOptions = (callIndex = 0) => mockGot.mock.calls[callIndex]?.[1] as any;
const makeConnectionError = (code: string, message = `connect ${code} 10.0.0.1:8080`) => {
const err = new RequestError(message);
(err as any).code = code;
return err;
};
it("uses hpagent proxy agents when ctx.proxyUrl is set", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
expect(gotOptions().agent.http).toBeInstanceOf(HttpProxyAgent);
expect(gotOptions().agent.https).toBeInstanceOf(HttpsProxyAgent);
});
it("uses the shared agents when ctx has no proxyUrl", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor());
await provider.handle(makeMonitor(), {});
expect(gotOptions(0).agent.http).not.toBeInstanceOf(HttpProxyAgent);
expect(gotOptions(1).agent.http).toBe(gotOptions(0).agent.http);
expect(gotOptions(1).agent.https).toBe(gotOptions(0).agent.https);
});
it("reuses the cached agent pair across checks with the same proxyUrl", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
expect(gotOptions(1).agent.http).toBe(gotOptions(0).agent.http);
expect(gotOptions(1).agent.https).toBe(gotOptions(0).agent.https);
});
it("builds distinct agent pairs for distinct proxyUrls", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
await provider.handle(makeMonitor(), { proxyUrl: "http://other.example.com:3128" });
expect(gotOptions(1).agent.http).not.toBe(gotOptions(0).agent.http);
});
it("builds distinct pairs per ignoreTlsErrors and sets rejectUnauthorized accordingly", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor({ ignoreTlsErrors: false }), { proxyUrl: PROXY_URL });
await provider.handle(makeMonitor({ ignoreTlsErrors: true }), { proxyUrl: PROXY_URL });
expect(gotOptions(1).agent.https).not.toBe(gotOptions(0).agent.https);
expect(gotOptions(0).agent.https.options.rejectUnauthorized).toBe(true);
expect(gotOptions(1).agent.https.options.rejectUnauthorized).toBe(false);
});
it("evicts and destroys the least-recently-used pair past the cache cap", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor(), { proxyUrl: "http://proxy0.example.com:8080" });
const first = gotOptions(0).agent;
const httpDestroy = jest.spyOn(first.http, "destroy");
const httpsDestroy = jest.spyOn(first.https, "destroy");
for (let i = 1; i <= 49; i++) {
await provider.handle(makeMonitor(), { proxyUrl: `http://proxy${i}.example.com:8080` });
}
expect(httpDestroy).not.toHaveBeenCalled();
await provider.handle(makeMonitor(), { proxyUrl: "http://proxy50.example.com:8080" });
expect(httpDestroy).toHaveBeenCalled();
expect(httpsDestroy).toHaveBeenCalled();
// The evicted pair is gone — the same proxyUrl now gets a fresh pair
await provider.handle(makeMonitor(), { proxyUrl: "http://proxy0.example.com:8080" });
expect(gotOptions(51).agent.http).not.toBe(first.http);
});
it("names the proxy in the message when the proxy connection fails", async () => {
mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED"));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
expect(result.status).toBe(false);
expect(result.code).toBe(NETWORK_ERROR);
expect(result.message).toBe("Proxy connection failed (proxy.example.com:8080): connect ECONNREFUSED 10.0.0.1:8080");
});
it("does not attribute timeouts to the proxy", async () => {
mockGot.mockRejectedValue(makeConnectionError("ETIMEDOUT", "Timeout awaiting 'request' for 30000ms"));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
expect(result.status).toBe(false);
expect(result.message).toBe("Timeout awaiting 'request' for 30000ms");
});
it("leaves connection-error messages unprefixed for direct checks", async () => {
mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED"));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.message).toBe("connect ECONNREFUSED 10.0.0.1:8080");
});
it("never leaks proxy credentials into the message", async () => {
mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED"));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor(), { proxyUrl: "http://bob:sekret@proxy.example.com:8080" });
expect(result.message).toContain("proxy.example.com:8080");
expect(result.message).not.toContain("bob");
expect(result.message).not.toContain("sekret");
});
it("does not prefix errors that carry a response (target failure through a healthy proxy)", async () => {
const err = new RequestError("Response code 500 (Internal Server Error)");
(err as any).code = "ECONNRESET";
(err as any).response = { statusCode: 500, body: "", headers: {} };
(err as any).timings = { phases: { total: 25 } };
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
expect(result.status).toBe(false);
expect(result.code).toBe(500);
expect(result.message).toBe("Response code 500 (Internal Server Error)");
});
});
});