From 9c03f8be088ede7495bb83779bb623ffd6498793 Mon Sep 17 00:00:00 2001 From: Hyup Date: Mon, 2 Mar 2026 12:00:33 +0900 Subject: [PATCH] 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 Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + src/telegram/fetch.test.ts | 91 +++++++++++++++++++++++++++++++++++++ src/telegram/fetch.ts | 92 +++++++++++++++++++++++++++++++++++--- 3 files changed, 178 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b718a56ff..5e3c3d2df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,6 +124,7 @@ Docs: https://docs.openclaw.ai - Nodes/Screen recording guardrails: cap `nodes` tool `screen_record` `durationMs` to 5 minutes at both schema-validation and runtime invocation layers to prevent long-running blocking captures from unbounded durations. Landed from contributor PR #31106 by @BlueBirdBack. Thanks @BlueBirdBack. - Telegram/Empty final replies: skip outbound send for null/undefined final text payloads without media so Telegram typing indicators do not linger on `text must be non-empty` errors, with added regression coverage for undefined final payload dispatch. Landed from contributor PRs #30969 by @haosenwang1018 and #30746 by @rylena. Thanks @haosenwang1018 and @rylena. - Telegram/Proxy dispatcher preservation: preserve proxy-aware global undici dispatcher behavior in Telegram network workarounds so proxy-backed Telegram + model traffic is not broken by dispatcher replacement. Landed from contributor PR #30367 by @Phineas1500. Thanks @Phineas1500. +- Telegram/Media fetch IPv4 fallback: retry Telegram media fetches once with IPv4-first dispatcher settings when dual-stack connect errors (`ETIMEDOUT`/`ENETUNREACH`/`EHOSTUNREACH`) occur, improving reliability on broken IPv6 routes. Landed from contributor PR #30554 by @bosuksh. Thanks @bosuksh. - Telegram/Group allowlist ordering: evaluate chat allowlist before sender allowlist enforcement so explicitly allowlisted groups are not fail-closed by empty sender allowlists. Landed from contributor PR #30680 by @openperf. Thanks @openperf. - Telegram/Multi-account group isolation: prevent channel-level `groups` config from leaking across Telegram accounts in multi-account setups, avoiding cross-account group routing drops. Landed from contributor PR #30677 by @YUJIE2002. Thanks @YUJIE2002. - Telegram/Voice caption overflow fallback: recover from `sendVoice` caption length errors by re-sending voice without caption and delivering text separately so replies are not lost. Landed from contributor PR #31131 by @Sid-Qin. Thanks @Sid-Qin. diff --git a/src/telegram/fetch.test.ts b/src/telegram/fetch.test.ts index ca040e568..90da589f8 100644 --- a/src/telegram/fetch.test.ts +++ b/src/telegram/fetch.test.ts @@ -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); + }); }); diff --git a/src/telegram/fetch.ts b/src/telegram/fetch.ts index 569d2c338..ebcb6a7ec 100644 --- a/src/telegram/fetch.ts +++ b/src/telegram/fetch.ts @@ -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 { + const codes = new Set(); + const queue: unknown[] = [err]; + const seen = new Set(); + + 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 {