telegram: retry media fetch with IPv4 fallback on connect errors (#30554)

* telegram: retry fetch once with IPv4 fallback on connect errors

* test(telegram): format fetch fallback test

* style(telegram): apply oxfmt for fetch test

* fix(telegram): retry ipv4 fallback per request

* test: harden telegram ipv4 fallback coverage (#30554)

---------

Co-authored-by: root <root@vultr.guest>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Hyup
2026-03-02 12:00:33 +09:00
committed by GitHub
parent 31c4722e90
commit 9c03f8be08
3 changed files with 178 additions and 6 deletions

View File

@@ -217,4 +217,95 @@ describe("resolveTelegramFetch", () => {
},
});
});
it("retries once with ipv4 fallback when fetch fails with network timeout/unreachable", async () => {
const timeoutErr = Object.assign(new Error("connect ETIMEDOUT 149.154.166.110:443"), {
code: "ETIMEDOUT",
});
const unreachableErr = Object.assign(
new Error("connect ENETUNREACH 2001:67c:4e8:f004::9:443"),
{
code: "ENETUNREACH",
},
);
const fetchError = Object.assign(new TypeError("fetch failed"), {
cause: Object.assign(new Error("aggregate"), {
errors: [timeoutErr, unreachableErr],
}),
});
const fetchMock = vi
.fn()
.mockRejectedValueOnce(fetchError)
.mockResolvedValueOnce({ ok: true } as Response);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const resolved = resolveTelegramFetch();
if (!resolved) {
throw new Error("expected resolved fetch");
}
await resolved("https://api.telegram.org/file/botx/photos/file_1.jpg");
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(setGlobalDispatcher).toHaveBeenCalledTimes(2);
expect(EnvHttpProxyAgentCtor).toHaveBeenNthCalledWith(1, {
connect: {
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 300,
},
});
expect(EnvHttpProxyAgentCtor).toHaveBeenNthCalledWith(2, {
connect: {
autoSelectFamily: false,
autoSelectFamilyAttemptTimeout: 300,
},
});
});
it("retries with ipv4 fallback once per request, not once per process", async () => {
const timeoutErr = Object.assign(new Error("connect ETIMEDOUT 149.154.166.110:443"), {
code: "ETIMEDOUT",
});
const fetchError = Object.assign(new TypeError("fetch failed"), {
cause: timeoutErr,
});
const fetchMock = vi
.fn()
.mockRejectedValueOnce(fetchError)
.mockResolvedValueOnce({ ok: true } as Response)
.mockRejectedValueOnce(fetchError)
.mockResolvedValueOnce({ ok: true } as Response);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const resolved = resolveTelegramFetch();
if (!resolved) {
throw new Error("expected resolved fetch");
}
await resolved("https://api.telegram.org/file/botx/photos/file_1.jpg");
await resolved("https://api.telegram.org/file/botx/photos/file_2.jpg");
expect(fetchMock).toHaveBeenCalledTimes(4);
});
it("does not retry when fetch fails without fallback network error codes", async () => {
const fetchError = Object.assign(new TypeError("fetch failed"), {
cause: Object.assign(new Error("connect ECONNRESET"), {
code: "ECONNRESET",
}),
});
const fetchMock = vi.fn().mockRejectedValue(fetchError);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const resolved = resolveTelegramFetch();
if (!resolved) {
throw new Error("expected resolved fetch");
}
await expect(resolved("https://api.telegram.org/file/botx/photos/file_3.jpg")).rejects.toThrow(
"fetch failed",
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -37,6 +37,14 @@ function isProxyLikeDispatcher(dispatcher: unknown): boolean {
return typeof ctorName === "string" && ctorName.includes("ProxyAgent");
}
const FALLBACK_RETRY_ERROR_CODES = new Set([
"ETIMEDOUT",
"ENETUNREACH",
"EHOSTUNREACH",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_SOCKET",
]);
// Node 22 workaround: enable autoSelectFamily to allow IPv4 fallback on broken IPv6 networks.
// Many networks have IPv6 configured but not routed, causing "Network is unreachable" errors.
// See: https://github.com/nodejs/node/issues/54359
@@ -106,20 +114,92 @@ function applyTelegramNetworkWorkarounds(network?: TelegramNetworkConfig): void
}
}
function collectErrorCodes(err: unknown): Set<string> {
const codes = new Set<string>();
const queue: unknown[] = [err];
const seen = new Set<unknown>();
while (queue.length > 0) {
const current = queue.shift();
if (!current || seen.has(current)) {
continue;
}
seen.add(current);
if (typeof current === "object") {
const code = (current as { code?: unknown }).code;
if (typeof code === "string" && code.trim()) {
codes.add(code.trim().toUpperCase());
}
const cause = (current as { cause?: unknown }).cause;
if (cause && !seen.has(cause)) {
queue.push(cause);
}
const errors = (current as { errors?: unknown }).errors;
if (Array.isArray(errors)) {
for (const nested of errors) {
if (nested && !seen.has(nested)) {
queue.push(nested);
}
}
}
}
}
return codes;
}
function shouldRetryWithIpv4Fallback(err: unknown): boolean {
const message =
err && typeof err === "object" && "message" in err ? String(err.message).toLowerCase() : "";
if (!message.includes("fetch failed")) {
return false;
}
const codes = collectErrorCodes(err);
if (codes.size === 0) {
return false;
}
for (const code of codes) {
if (FALLBACK_RETRY_ERROR_CODES.has(code)) {
return true;
}
}
return false;
}
function applyTelegramIpv4Fallback(): void {
applyTelegramNetworkWorkarounds({
autoSelectFamily: false,
dnsResultOrder: "ipv4first",
});
log.warn("fetch fallback: forcing autoSelectFamily=false + dnsResultOrder=ipv4first");
}
// Prefer wrapped fetch when available to normalize AbortSignal across runtimes.
export function resolveTelegramFetch(
proxyFetch?: typeof fetch,
options?: { network?: TelegramNetworkConfig },
): typeof fetch | undefined {
applyTelegramNetworkWorkarounds(options?.network);
if (proxyFetch) {
return resolveFetch(proxyFetch);
}
const fetchImpl = resolveFetch();
if (!fetchImpl) {
const sourceFetch = proxyFetch ? resolveFetch(proxyFetch) : resolveFetch();
if (!sourceFetch) {
throw new Error("fetch is not available; set channels.telegram.proxy in config");
}
return fetchImpl;
// When Telegram media fetch hits dual-stack edge cases (ENETUNREACH/ETIMEDOUT),
// switch to IPv4-safe network mode and retry once.
if (proxyFetch) {
return sourceFetch;
}
return (async (input: RequestInfo | URL, init?: RequestInit) => {
try {
return await sourceFetch(input, init);
} catch (err) {
if (shouldRetryWithIpv4Fallback(err)) {
applyTelegramIpv4Fallback();
return sourceFetch(input, init);
}
throw err;
}
}) as typeof fetch;
}
export function resetTelegramFetchStateForTests(): void {