Skip to content

Commit 9c03f8b

Browse files
bosukshHarukaMasteipete
authored
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 <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent 31c4722 commit 9c03f8b

File tree

3 files changed

+178
-6
lines changed

3 files changed

+178
-6
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ Docs: https://docs.openclaw.ai
124124
- 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.
125125
- 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.
126126
- 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.
127+
- 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.
127128
- 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.
128129
- 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.
129130
- 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.

src/telegram/fetch.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,4 +217,95 @@ describe("resolveTelegramFetch", () => {
217217
},
218218
});
219219
});
220+
221+
it("retries once with ipv4 fallback when fetch fails with network timeout/unreachable", async () => {
222+
const timeoutErr = Object.assign(new Error("connect ETIMEDOUT 149.154.166.110:443"), {
223+
code: "ETIMEDOUT",
224+
});
225+
const unreachableErr = Object.assign(
226+
new Error("connect ENETUNREACH 2001:67c:4e8:f004::9:443"),
227+
{
228+
code: "ENETUNREACH",
229+
},
230+
);
231+
const fetchError = Object.assign(new TypeError("fetch failed"), {
232+
cause: Object.assign(new Error("aggregate"), {
233+
errors: [timeoutErr, unreachableErr],
234+
}),
235+
});
236+
const fetchMock = vi
237+
.fn()
238+
.mockRejectedValueOnce(fetchError)
239+
.mockResolvedValueOnce({ ok: true } as Response);
240+
globalThis.fetch = fetchMock as unknown as typeof fetch;
241+
242+
const resolved = resolveTelegramFetch();
243+
if (!resolved) {
244+
throw new Error("expected resolved fetch");
245+
}
246+
247+
await resolved("https://api.telegram.org/file/botx/photos/file_1.jpg");
248+
249+
expect(fetchMock).toHaveBeenCalledTimes(2);
250+
expect(setGlobalDispatcher).toHaveBeenCalledTimes(2);
251+
expect(EnvHttpProxyAgentCtor).toHaveBeenNthCalledWith(1, {
252+
connect: {
253+
autoSelectFamily: true,
254+
autoSelectFamilyAttemptTimeout: 300,
255+
},
256+
});
257+
expect(EnvHttpProxyAgentCtor).toHaveBeenNthCalledWith(2, {
258+
connect: {
259+
autoSelectFamily: false,
260+
autoSelectFamilyAttemptTimeout: 300,
261+
},
262+
});
263+
});
264+
265+
it("retries with ipv4 fallback once per request, not once per process", async () => {
266+
const timeoutErr = Object.assign(new Error("connect ETIMEDOUT 149.154.166.110:443"), {
267+
code: "ETIMEDOUT",
268+
});
269+
const fetchError = Object.assign(new TypeError("fetch failed"), {
270+
cause: timeoutErr,
271+
});
272+
const fetchMock = vi
273+
.fn()
274+
.mockRejectedValueOnce(fetchError)
275+
.mockResolvedValueOnce({ ok: true } as Response)
276+
.mockRejectedValueOnce(fetchError)
277+
.mockResolvedValueOnce({ ok: true } as Response);
278+
globalThis.fetch = fetchMock as unknown as typeof fetch;
279+
280+
const resolved = resolveTelegramFetch();
281+
if (!resolved) {
282+
throw new Error("expected resolved fetch");
283+
}
284+
285+
await resolved("https://api.telegram.org/file/botx/photos/file_1.jpg");
286+
await resolved("https://api.telegram.org/file/botx/photos/file_2.jpg");
287+
288+
expect(fetchMock).toHaveBeenCalledTimes(4);
289+
});
290+
291+
it("does not retry when fetch fails without fallback network error codes", async () => {
292+
const fetchError = Object.assign(new TypeError("fetch failed"), {
293+
cause: Object.assign(new Error("connect ECONNRESET"), {
294+
code: "ECONNRESET",
295+
}),
296+
});
297+
const fetchMock = vi.fn().mockRejectedValue(fetchError);
298+
globalThis.fetch = fetchMock as unknown as typeof fetch;
299+
300+
const resolved = resolveTelegramFetch();
301+
if (!resolved) {
302+
throw new Error("expected resolved fetch");
303+
}
304+
305+
await expect(resolved("https://api.telegram.org/file/botx/photos/file_3.jpg")).rejects.toThrow(
306+
"fetch failed",
307+
);
308+
309+
expect(fetchMock).toHaveBeenCalledTimes(1);
310+
});
220311
});

src/telegram/fetch.ts

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ function isProxyLikeDispatcher(dispatcher: unknown): boolean {
3737
return typeof ctorName === "string" && ctorName.includes("ProxyAgent");
3838
}
3939

40+
const FALLBACK_RETRY_ERROR_CODES = new Set([
41+
"ETIMEDOUT",
42+
"ENETUNREACH",
43+
"EHOSTUNREACH",
44+
"UND_ERR_CONNECT_TIMEOUT",
45+
"UND_ERR_SOCKET",
46+
]);
47+
4048
// Node 22 workaround: enable autoSelectFamily to allow IPv4 fallback on broken IPv6 networks.
4149
// Many networks have IPv6 configured but not routed, causing "Network is unreachable" errors.
4250
// See: https://github.com/nodejs/node/issues/54359
@@ -106,20 +114,92 @@ function applyTelegramNetworkWorkarounds(network?: TelegramNetworkConfig): void
106114
}
107115
}
108116

117+
function collectErrorCodes(err: unknown): Set<string> {
118+
const codes = new Set<string>();
119+
const queue: unknown[] = [err];
120+
const seen = new Set<unknown>();
121+
122+
while (queue.length > 0) {
123+
const current = queue.shift();
124+
if (!current || seen.has(current)) {
125+
continue;
126+
}
127+
seen.add(current);
128+
if (typeof current === "object") {
129+
const code = (current as { code?: unknown }).code;
130+
if (typeof code === "string" && code.trim()) {
131+
codes.add(code.trim().toUpperCase());
132+
}
133+
const cause = (current as { cause?: unknown }).cause;
134+
if (cause && !seen.has(cause)) {
135+
queue.push(cause);
136+
}
137+
const errors = (current as { errors?: unknown }).errors;
138+
if (Array.isArray(errors)) {
139+
for (const nested of errors) {
140+
if (nested && !seen.has(nested)) {
141+
queue.push(nested);
142+
}
143+
}
144+
}
145+
}
146+
}
147+
148+
return codes;
149+
}
150+
151+
function shouldRetryWithIpv4Fallback(err: unknown): boolean {
152+
const message =
153+
err && typeof err === "object" && "message" in err ? String(err.message).toLowerCase() : "";
154+
if (!message.includes("fetch failed")) {
155+
return false;
156+
}
157+
const codes = collectErrorCodes(err);
158+
if (codes.size === 0) {
159+
return false;
160+
}
161+
for (const code of codes) {
162+
if (FALLBACK_RETRY_ERROR_CODES.has(code)) {
163+
return true;
164+
}
165+
}
166+
return false;
167+
}
168+
169+
function applyTelegramIpv4Fallback(): void {
170+
applyTelegramNetworkWorkarounds({
171+
autoSelectFamily: false,
172+
dnsResultOrder: "ipv4first",
173+
});
174+
log.warn("fetch fallback: forcing autoSelectFamily=false + dnsResultOrder=ipv4first");
175+
}
176+
109177
// Prefer wrapped fetch when available to normalize AbortSignal across runtimes.
110178
export function resolveTelegramFetch(
111179
proxyFetch?: typeof fetch,
112180
options?: { network?: TelegramNetworkConfig },
113181
): typeof fetch | undefined {
114182
applyTelegramNetworkWorkarounds(options?.network);
115-
if (proxyFetch) {
116-
return resolveFetch(proxyFetch);
117-
}
118-
const fetchImpl = resolveFetch();
119-
if (!fetchImpl) {
183+
const sourceFetch = proxyFetch ? resolveFetch(proxyFetch) : resolveFetch();
184+
if (!sourceFetch) {
120185
throw new Error("fetch is not available; set channels.telegram.proxy in config");
121186
}
122-
return fetchImpl;
187+
// When Telegram media fetch hits dual-stack edge cases (ENETUNREACH/ETIMEDOUT),
188+
// switch to IPv4-safe network mode and retry once.
189+
if (proxyFetch) {
190+
return sourceFetch;
191+
}
192+
return (async (input: RequestInfo | URL, init?: RequestInit) => {
193+
try {
194+
return await sourceFetch(input, init);
195+
} catch (err) {
196+
if (shouldRetryWithIpv4Fallback(err)) {
197+
applyTelegramIpv4Fallback();
198+
return sourceFetch(input, init);
199+
}
200+
throw err;
201+
}
202+
}) as typeof fetch;
123203
}
124204

125205
export function resetTelegramFetchStateForTests(): void {

0 commit comments

Comments
 (0)