mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 06:32:16 +03:00
fix(proxy): stop reporting IPv4-only proxies as dead (#10868)
Obrigado — bug real e bem raiz-causado: api64.ipify.org é IPv6-first e derruba tunnels IPv4-only, o que estava reportando proxies vivos como mortos. Validação (worktree combinado a partir de origin/release/v3.8.50, 0 conflitos): - typecheck:core limpo, complexity 2563/2774, cognitive-complexity 1155/1223 (baseline) - tests/unit/proxy-echo-ipv4-fallback-9694.test.ts — 8/8 passando (cobre ordem, split de budget, override, proxy morto de verdade) - Suítes proxy-relacionadas: 805/817 na branch vs 797/809 no release, as 11 falhas são idênticas em ambos os lados e não relacionadas (TLS transport, tproxy CA, SSRF fallback)
This commit is contained in:
1
changelog.d/fixes/10868-proxy-echo-ipv4-fallback.md
Normal file
1
changelog.d/fixes/10868-proxy-echo-ipv4-fallback.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(proxy):** proxy "Test connection" no longer reports an IPv4-only SOCKS5/SSH proxy as dead. #1255 moved every egress probe from `api.ipify.org` to `api64.ipify.org` so proxies with IPv6 egress could be tested, but `api64` is IPv6-first: a tunnel with no IPv6 route has nothing to connect to, so the probe hung until the caller's deadline and a proxy that was carrying live LLM traffic came back as a failure. Swapping the target to `api4` fixes that case and re-breaks the one #1255 fixed, so the probe now tries the targets in order instead — `api64` first, so a proxy with working IPv6 answers on the first attempt and keeps the exact behaviour #1255 introduced, including which of its addresses is reported (the egress IP is used as an identity to detect accounts of one rotation group sharing an address, so the attempts are sequential rather than raced). The attempts split the budget each call site already enforced, so no probe can take longer than it could before, and each attempt gets its own `AbortController` so exhausting the budget on an unreachable target does not abort the next one. `OMNIROUTE_PROXY_ECHO_URL` pins a single target — including a self-hosted echo — replacing the workaround of rewriting the compiled bundle after every upgrade. The relay branch of the test route still targets `api64` through `x-relay-target`, since that request egresses from the relay worker rather than the operator's tunnel
|
||||
@@ -349,6 +349,7 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con
|
||||
| `HTTP_PROXY` | _(unset)_ | Node.js standard | HTTP proxy for upstream calls. |
|
||||
| `HTTPS_PROXY` | _(unset)_ | Node.js standard | HTTPS proxy for upstream calls. |
|
||||
| `ALL_PROXY` | _(unset)_ | Node.js standard | Universal proxy (supports `socks5://`). |
|
||||
| `OMNIROUTE_PROXY_ECHO_URL` | _(unset)_ | `src/lib/proxyEchoTarget.ts` | Pins the echo-IP target used by proxy egress probes to a single URL. Unset, the probe tries `api64.ipify.org` then `api4.ipify.org` so IPv4-only tunnels are not reported dead (#9694). |
|
||||
| `NO_PROXY` | _(unset)_ | Node.js standard | Comma-separated hostnames/IPs to bypass the proxy. |
|
||||
| `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS` | `32` | `open-sse/utils/proxyDispatcher.ts` | Max concurrent sockets per cached HTTP/SOCKS proxy dispatcher. Long-lived SSE streams such as Codex `/v1/responses` need more than one connection when several requests share the same account-level proxy. Values above `256` are capped. |
|
||||
| `SOCKS_HANDSHAKE_TIMEOUT_MS` | `10000` | `open-sse/utils/socksConnectorWithFamily.ts` | SOCKS5 handshake (connect) timeout in ms. Raise it when a single residential gateway host is hit by high concurrency (e.g. 100 simultaneous requests) — the real handshake can exceed 10s under a saturated pool even though the proxy is reachable, which otherwise surfaces as a false `[Proxy Fast-Fail] Proxy unreachable`. Capped at `120000`. |
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createProxyDispatcher,
|
||||
proxyConfigToUrl,
|
||||
} from "@omniroute/open-sse/utils/proxyDispatcher.ts";
|
||||
import { probeEchoTargets } from "@/lib/proxyEchoTarget";
|
||||
|
||||
type ConnectivityTester = (
|
||||
host: string,
|
||||
@@ -23,31 +24,37 @@ async function testProxyConnectivity(
|
||||
|
||||
const dispatcher = createProxyDispatcher(proxyUrl);
|
||||
const start = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const res = await undiciRequest("https://api64.ipify.org?format=json", {
|
||||
method: "GET",
|
||||
dispatcher,
|
||||
signal: controller.signal,
|
||||
headersTimeout: 5000,
|
||||
bodyTimeout: 5000,
|
||||
});
|
||||
const text = await res.body.text();
|
||||
// #9694: try the IPv6-first echo target, then the IPv4-only one, so a proxy
|
||||
// with no IPv6 route is not reported dead.
|
||||
const { result } = await probeEchoTargets(async (url, timeoutMs) => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await undiciRequest(url, {
|
||||
method: "GET",
|
||||
dispatcher,
|
||||
signal: controller.signal,
|
||||
headersTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
});
|
||||
return { statusCode: res.statusCode, text: await res.body.text() };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}, 5000);
|
||||
let parsed: { ip?: string } = {};
|
||||
try {
|
||||
parsed = JSON.parse(text) as { ip?: string };
|
||||
parsed = JSON.parse(result.text) as { ip?: string };
|
||||
} catch {}
|
||||
return {
|
||||
success: res.statusCode === 200,
|
||||
success: result.statusCode === 200,
|
||||
latencyMs: Date.now() - start,
|
||||
publicIp: parsed.ip,
|
||||
};
|
||||
} catch {
|
||||
return { success: false, latencyMs: Date.now() - start };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
createProxyDispatcher,
|
||||
proxyConfigToUrl,
|
||||
} from "@omniroute/open-sse/utils/proxyDispatcher.ts";
|
||||
import { probeEchoTargets } from "@/lib/proxyEchoTarget";
|
||||
|
||||
type QuickTester = (
|
||||
host: string,
|
||||
@@ -24,22 +25,29 @@ async function testProxyQuick(
|
||||
if (!proxyUrl) return { ok: false, latencyMs: 0 };
|
||||
const dispatcher = createProxyDispatcher(proxyUrl);
|
||||
const start = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
try {
|
||||
const res = await undiciRequest("https://api64.ipify.org?format=json", {
|
||||
method: "GET",
|
||||
dispatcher,
|
||||
signal: controller.signal,
|
||||
headersTimeout: 5000,
|
||||
bodyTimeout: 5000,
|
||||
});
|
||||
await res.body.dump();
|
||||
return { ok: res.statusCode === 200, latencyMs: Date.now() - start };
|
||||
// #9694: try the IPv6-first echo target, then the IPv4-only one, so a proxy
|
||||
// with no IPv6 route is not reported dead.
|
||||
const { result } = await probeEchoTargets(async (url, timeoutMs) => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await undiciRequest(url, {
|
||||
method: "GET",
|
||||
dispatcher,
|
||||
signal: controller.signal,
|
||||
headersTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
});
|
||||
await res.body.dump();
|
||||
return res.statusCode;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}, 5000);
|
||||
return { ok: result === 200, latencyMs: Date.now() - start };
|
||||
} catch {
|
||||
return { ok: false, latencyMs: Date.now() - start };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
proxyConfigToUrl,
|
||||
proxyUrlForLogs,
|
||||
} from "@omniroute/open-sse/utils/proxyDispatcher.ts";
|
||||
import { probeEchoTargets } from "@/lib/proxyEchoTarget";
|
||||
import { testProxySchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
|
||||
@@ -215,20 +216,28 @@ export async function POST(request: Request) {
|
||||
const publicProxyUrl = proxyUrlForLogs(proxyUrl);
|
||||
|
||||
const startTime = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
const dispatcher = createProxyDispatcher(proxyUrl);
|
||||
|
||||
try {
|
||||
const result = await undiciRequest("https://api64.ipify.org?format=json", {
|
||||
method: "GET",
|
||||
dispatcher,
|
||||
signal: controller.signal,
|
||||
headersTimeout: 10000,
|
||||
bodyTimeout: 10000,
|
||||
});
|
||||
|
||||
const responseText = await result.body.text();
|
||||
// #9694: an IPv4-only SOCKS5/SSH tunnel has no route to the IPv6-first
|
||||
// echo target and used to hang here until the deadline, reporting a
|
||||
// healthy proxy as dead. Each target gets its own slice of the budget.
|
||||
const { result: responseText } = await probeEchoTargets(async (url, timeoutMs) => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const result = await undiciRequest(url, {
|
||||
method: "GET",
|
||||
dispatcher,
|
||||
signal: controller.signal,
|
||||
headersTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
});
|
||||
return await result.body.text();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}, 10000);
|
||||
let parsed: { ip?: string };
|
||||
try {
|
||||
const parsedJson = JSON.parse(responseText);
|
||||
@@ -260,8 +269,6 @@ export async function POST(request: Request) {
|
||||
latencyMs: Date.now() - startTime,
|
||||
proxyUrl: publicProxyUrl,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Unexpected server error");
|
||||
|
||||
90
src/lib/proxyEchoTarget.ts
Normal file
90
src/lib/proxyEchoTarget.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* #9694 — echo-IP target selection for proxy egress probes.
|
||||
*
|
||||
* #1255 moved every probe from `api.ipify.org` to `api64.ipify.org` so proxies
|
||||
* with IPv6 egress could be tested. `api64` is IPv6-first, so it broke the case
|
||||
* the other way: an IPv4-only SOCKS5/SSH tunnel has no route to it and the probe
|
||||
* hangs until the caller's deadline, reporting a healthy proxy as dead.
|
||||
*
|
||||
* Neither single target works for both, so the probe tries them in order and
|
||||
* splits the caller's existing budget between the attempts. `api64` stays first,
|
||||
* so a proxy with working IPv6 answers on the first attempt and keeps the exact
|
||||
* behaviour #1255 introduced — including which of its addresses is reported,
|
||||
* which matters because the egress IP is an identity used to detect accounts
|
||||
* sharing an address. Only a proxy that cannot reach `api64` at all pays for the
|
||||
* second attempt, and the total stays bounded by the budget the caller already
|
||||
* enforced.
|
||||
*
|
||||
* Dependency-free leaf so the ordering and budget arithmetic are unit-testable
|
||||
* without opening a socket.
|
||||
*/
|
||||
|
||||
/** IPv6-first echo target (#1255). Answers over IPv4 too when IPv6 is unavailable to the resolver. */
|
||||
export const EGRESS_ECHO_URL_DUAL = "https://api64.ipify.org?format=json";
|
||||
|
||||
/** IPv4-only echo target — reachable from a proxy with no IPv6 route. */
|
||||
export const EGRESS_ECHO_URL_V4 = "https://api4.ipify.org?format=json";
|
||||
|
||||
/** Operators can pin a single target (including a self-hosted echo) per deployment. */
|
||||
export const EGRESS_ECHO_URL_ENV = "OMNIROUTE_PROXY_ECHO_URL";
|
||||
|
||||
/** Minimum a single attempt may be given, so a small caller budget is not split into uselessly short tries. */
|
||||
export const MIN_ECHO_ATTEMPT_MS = 2000;
|
||||
|
||||
/**
|
||||
* Ordered echo targets. An override pins exactly one target — an operator who
|
||||
* names a target means it, and silently trying ipify anyway would defeat the
|
||||
* point of pointing the probe at a self-hosted echo.
|
||||
*/
|
||||
export function resolveEgressEchoUrls(
|
||||
env: Record<string, string | undefined> = process.env
|
||||
): string[] {
|
||||
const override = env[EGRESS_ECHO_URL_ENV];
|
||||
if (typeof override === "string" && override.trim().length > 0) return [override.trim()];
|
||||
return [EGRESS_ECHO_URL_DUAL, EGRESS_ECHO_URL_V4];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-attempt budget. The attempts must fit inside the budget the caller already
|
||||
* enforces, so the deadline the operator sees does not move. A budget too small
|
||||
* to split fairly is spent entirely on the first target rather than on two
|
||||
* attempts that are each too short to succeed.
|
||||
*/
|
||||
export function splitEchoAttemptBudget(totalMs: number, attempts: number): number[] {
|
||||
if (!Number.isFinite(totalMs) || totalMs <= 0 || attempts <= 0) return [];
|
||||
if (attempts === 1) return [totalMs];
|
||||
const even = Math.floor(totalMs / attempts);
|
||||
if (even < MIN_ECHO_ATTEMPT_MS) return [totalMs];
|
||||
return Array.from({ length: attempts }, () => even);
|
||||
}
|
||||
|
||||
export interface EchoAttemptOutcome<T> {
|
||||
result: T;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try each echo target in order until one resolves. Rethrows the LAST error when
|
||||
* every target fails, so the caller's error message still describes a real
|
||||
* network failure rather than a bookkeeping one.
|
||||
*/
|
||||
export async function probeEchoTargets<T>(
|
||||
run: (url: string, timeoutMs: number) => Promise<T>,
|
||||
totalMs: number,
|
||||
env?: Record<string, string | undefined>
|
||||
): Promise<EchoAttemptOutcome<T>> {
|
||||
const urls = resolveEgressEchoUrls(env);
|
||||
const budgets = splitEchoAttemptBudget(totalMs, urls.length);
|
||||
const attempts = budgets.length;
|
||||
let lastError: unknown = new Error("no echo target attempted");
|
||||
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const url = urls[i];
|
||||
try {
|
||||
return { result: await run(url, budgets[i]), url };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
@@ -13,10 +13,13 @@
|
||||
* entering and leaving by.
|
||||
*/
|
||||
import { request as undiciRequest } from "undici";
|
||||
import { createProxyDispatcher, proxyConfigToUrl } from "@omniroute/open-sse/utils/proxyDispatcher.ts";
|
||||
import {
|
||||
createProxyDispatcher,
|
||||
proxyConfigToUrl,
|
||||
} from "@omniroute/open-sse/utils/proxyDispatcher.ts";
|
||||
import { rotationGroupFor } from "@omniroute/open-sse/services/refreshSerializer.ts";
|
||||
import { probeEchoTargets } from "./proxyEchoTarget";
|
||||
|
||||
const EGRESS_ECHO_URL = "https://api64.ipify.org?format=json";
|
||||
const EGRESS_PROBE_TIMEOUT_MS = 6000;
|
||||
const EGRESS_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
@@ -32,18 +35,26 @@ const egressCache = new Map<string, { ip: string | null; at: number }>();
|
||||
|
||||
async function defaultEgressProbe(proxyUrl: string | null): Promise<EgressProbeResult> {
|
||||
const start = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), EGRESS_PROBE_TIMEOUT_MS);
|
||||
try {
|
||||
const dispatcher = proxyUrl ? createProxyDispatcher(proxyUrl) : undefined;
|
||||
const res = await undiciRequest(EGRESS_ECHO_URL, {
|
||||
method: "GET",
|
||||
dispatcher,
|
||||
signal: controller.signal,
|
||||
headersTimeout: EGRESS_PROBE_TIMEOUT_MS,
|
||||
bodyTimeout: EGRESS_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
const text = await res.body.text();
|
||||
// #9694: each echo target gets its own controller, so exhausting the budget
|
||||
// on an unreachable IPv6-first target does not abort the IPv4 attempt.
|
||||
const { result: text } = await probeEchoTargets(async (url, timeoutMs) => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await undiciRequest(url, {
|
||||
method: "GET",
|
||||
dispatcher,
|
||||
signal: controller.signal,
|
||||
headersTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
});
|
||||
return await res.body.text();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}, EGRESS_PROBE_TIMEOUT_MS);
|
||||
let ip: string | null = null;
|
||||
try {
|
||||
ip = (JSON.parse(text) as { ip?: string }).ip ?? null;
|
||||
@@ -57,8 +68,6 @@ async function defaultEgressProbe(proxyUrl: string | null): Promise<EgressProbeR
|
||||
latencyMs: Date.now() - start,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +96,7 @@ export function getCachedEgressIp(proxyUrl: string | null): string | null {
|
||||
return null;
|
||||
}
|
||||
return cached.ip;
|
||||
}
|
||||
}
|
||||
|
||||
const warmingInFlight = new Set<string>();
|
||||
|
||||
@@ -195,9 +204,7 @@ export async function diagnoseAllEgressIps(deps?: {
|
||||
getConnections?: () => Promise<
|
||||
Array<{ id: string; provider: string; name?: string; email?: string; authType?: string }>
|
||||
>;
|
||||
resolveProxy?: (
|
||||
connectionId: string
|
||||
) => Promise<{ proxy?: unknown; level?: string } | null>;
|
||||
resolveProxy?: (connectionId: string) => Promise<{ proxy?: unknown; level?: string } | null>;
|
||||
}): Promise<EgressDiagnostic> {
|
||||
const getConnections =
|
||||
deps?.getConnections ??
|
||||
@@ -266,9 +273,21 @@ export interface ProxyValidationResult {
|
||||
*/
|
||||
export async function validateProxyPool(deps?: {
|
||||
listProxies?: () => Promise<
|
||||
Array<{ id: string; type: string; host: string; port: number | string; username?: string | null; password?: string | null; status?: string | null }>
|
||||
Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
host: string;
|
||||
port: number | string;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
status?: string | null;
|
||||
}>
|
||||
>;
|
||||
markStatus?: (id: string, status: string, meta: { latencyMs: number; egressIp: string | null }) => Promise<void>;
|
||||
markStatus?: (
|
||||
id: string,
|
||||
status: string,
|
||||
meta: { latencyMs: number; egressIp: string | null }
|
||||
) => Promise<void>;
|
||||
}): Promise<ProxyValidationResult[]> {
|
||||
const listProxies =
|
||||
deps?.listProxies ??
|
||||
@@ -351,7 +370,11 @@ export function planProxyDistribution(
|
||||
return;
|
||||
}
|
||||
if (opts.allowSharing) {
|
||||
assignments.push({ connectionId: c.id, account, proxyId: liveProxyIds[i % liveProxyIds.length] });
|
||||
assignments.push({
|
||||
connectionId: c.id,
|
||||
account,
|
||||
proxyId: liveProxyIds[i % liveProxyIds.length],
|
||||
});
|
||||
} else if (i < liveProxyIds.length) {
|
||||
assignments.push({ connectionId: c.id, account, proxyId: liveProxyIds[i] });
|
||||
} else {
|
||||
|
||||
138
tests/unit/proxy-echo-ipv4-fallback-9694.test.ts
Normal file
138
tests/unit/proxy-echo-ipv4-fallback-9694.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* #9694 — proxy "Test connection" false-negative on IPv4-only SOCKS5/SSH proxies.
|
||||
*
|
||||
* #1255 moved every egress probe to `api64.ipify.org`, which is IPv6-first, so a
|
||||
* proxy with no IPv6 route has nothing to connect to and the probe hangs until the
|
||||
* caller's deadline — a healthy proxy reported dead. Swapping the target to
|
||||
* `api4.ipify.org` fixes that case and breaks #1255's.
|
||||
*
|
||||
* The probe now tries the targets in order inside the budget the caller already
|
||||
* enforced. `api64` stays FIRST so a proxy with working IPv6 behaves exactly as it
|
||||
* did after #1255 — including which of its addresses is reported, which matters
|
||||
* because the egress IP is used as an identity to detect accounts sharing an address.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
probeEchoTargets,
|
||||
resolveEgressEchoUrls,
|
||||
splitEchoAttemptBudget,
|
||||
EGRESS_ECHO_URL_DUAL,
|
||||
EGRESS_ECHO_URL_V4,
|
||||
EGRESS_ECHO_URL_ENV,
|
||||
MIN_ECHO_ATTEMPT_MS,
|
||||
} = await import("../../src/lib/proxyEchoTarget.ts");
|
||||
|
||||
test("#9694: the IPv6-first target is still tried first", () => {
|
||||
assert.deepEqual(resolveEgressEchoUrls({}), [EGRESS_ECHO_URL_DUAL, EGRESS_ECHO_URL_V4]);
|
||||
assert.equal(EGRESS_ECHO_URL_DUAL, "https://api64.ipify.org?format=json");
|
||||
assert.equal(EGRESS_ECHO_URL_V4, "https://api4.ipify.org?format=json");
|
||||
});
|
||||
|
||||
test("#9694: an operator override pins exactly one target", () => {
|
||||
const env = { [EGRESS_ECHO_URL_ENV]: " https://echo.internal/ip " };
|
||||
assert.deepEqual(resolveEgressEchoUrls(env), ["https://echo.internal/ip"]);
|
||||
for (const blank of ["", " "]) {
|
||||
assert.deepEqual(resolveEgressEchoUrls({ [EGRESS_ECHO_URL_ENV]: blank }), [
|
||||
EGRESS_ECHO_URL_DUAL,
|
||||
EGRESS_ECHO_URL_V4,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("#9694: attempts share the caller's budget instead of extending it", () => {
|
||||
// The real call sites use 5s, 6s and 10s.
|
||||
assert.deepEqual(splitEchoAttemptBudget(10000, 2), [5000, 5000]);
|
||||
assert.deepEqual(splitEchoAttemptBudget(6000, 2), [3000, 3000]);
|
||||
assert.deepEqual(splitEchoAttemptBudget(5000, 2), [2500, 2500]);
|
||||
for (const total of [10000, 6000, 5000]) {
|
||||
const budgets = splitEchoAttemptBudget(total, 2);
|
||||
assert.ok(
|
||||
budgets.reduce((a, b) => a + b, 0) <= total,
|
||||
"the sum must never exceed the deadline the caller already enforced"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("#9694: a budget too small to split is spent on one attempt, not two useless ones", () => {
|
||||
assert.deepEqual(splitEchoAttemptBudget(MIN_ECHO_ATTEMPT_MS * 2 - 2, 2), [
|
||||
MIN_ECHO_ATTEMPT_MS * 2 - 2,
|
||||
]);
|
||||
assert.deepEqual(splitEchoAttemptBudget(0, 2), []);
|
||||
assert.deepEqual(splitEchoAttemptBudget(-1, 2), []);
|
||||
assert.deepEqual(splitEchoAttemptBudget(10000, 0), []);
|
||||
assert.deepEqual(splitEchoAttemptBudget(10000, 1), [10000]);
|
||||
});
|
||||
|
||||
test("#9694: a reachable IPv6-first target is used and the IPv4 target is never touched", async () => {
|
||||
const tried: string[] = [];
|
||||
const outcome = await probeEchoTargets(
|
||||
async (url) => {
|
||||
tried.push(url);
|
||||
return '{"ip":"2001:db8::1"}';
|
||||
},
|
||||
10000,
|
||||
{}
|
||||
);
|
||||
assert.deepEqual(tried, [EGRESS_ECHO_URL_DUAL], "no extra request for a healthy IPv6 proxy");
|
||||
assert.equal(outcome.url, EGRESS_ECHO_URL_DUAL);
|
||||
assert.equal(outcome.result, '{"ip":"2001:db8::1"}');
|
||||
});
|
||||
|
||||
test("#9694: an IPv4-only proxy reaches the IPv4 target and succeeds", async () => {
|
||||
const tried: Array<{ url: string; timeoutMs: number }> = [];
|
||||
const outcome = await probeEchoTargets(
|
||||
async (url, timeoutMs) => {
|
||||
tried.push({ url, timeoutMs });
|
||||
// What an IPv4-only SOCKS5 tunnel does with an IPv6-first host: nothing,
|
||||
// until the attempt budget aborts it.
|
||||
if (url === EGRESS_ECHO_URL_DUAL) throw new Error("This operation was aborted");
|
||||
return '{"ip":"203.0.113.7"}';
|
||||
},
|
||||
10000,
|
||||
{}
|
||||
);
|
||||
assert.deepEqual(
|
||||
tried.map((t) => t.url),
|
||||
[EGRESS_ECHO_URL_DUAL, EGRESS_ECHO_URL_V4],
|
||||
"the IPv6 attempt must not end the probe"
|
||||
);
|
||||
assert.deepEqual(
|
||||
tried.map((t) => t.timeoutMs),
|
||||
[5000, 5000],
|
||||
"each attempt gets half of the caller's 10s budget"
|
||||
);
|
||||
assert.equal(outcome.url, EGRESS_ECHO_URL_V4);
|
||||
assert.equal(outcome.result, '{"ip":"203.0.113.7"}');
|
||||
});
|
||||
|
||||
test("#9694: a genuinely dead proxy still fails, with the last real error", async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
probeEchoTargets(
|
||||
async (url) => {
|
||||
throw new Error(`ECONNREFUSED ${url}`);
|
||||
},
|
||||
10000,
|
||||
{}
|
||||
),
|
||||
/ECONNREFUSED .*api4\.ipify\.org/,
|
||||
"the surfaced error must describe a network failure, not internal bookkeeping"
|
||||
);
|
||||
});
|
||||
|
||||
test("#9694: an override that fails is not silently retried against ipify", async () => {
|
||||
const tried: string[] = [];
|
||||
await assert.rejects(() =>
|
||||
probeEchoTargets(
|
||||
async (url) => {
|
||||
tried.push(url);
|
||||
throw new Error("nope");
|
||||
},
|
||||
10000,
|
||||
{ [EGRESS_ECHO_URL_ENV]: "https://echo.internal/ip" }
|
||||
)
|
||||
);
|
||||
assert.deepEqual(tried, ["https://echo.internal/ip"]);
|
||||
});
|
||||
Reference in New Issue
Block a user