mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-02 02:57:51 +00:00
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:
@@ -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.
|
- 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/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/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/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/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.
|
- 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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ function isProxyLikeDispatcher(dispatcher: unknown): boolean {
|
|||||||
return typeof ctorName === "string" && ctorName.includes("ProxyAgent");
|
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.
|
// 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.
|
// Many networks have IPv6 configured but not routed, causing "Network is unreachable" errors.
|
||||||
// See: https://github.com/nodejs/node/issues/54359
|
// 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.
|
// Prefer wrapped fetch when available to normalize AbortSignal across runtimes.
|
||||||
export function resolveTelegramFetch(
|
export function resolveTelegramFetch(
|
||||||
proxyFetch?: typeof fetch,
|
proxyFetch?: typeof fetch,
|
||||||
options?: { network?: TelegramNetworkConfig },
|
options?: { network?: TelegramNetworkConfig },
|
||||||
): typeof fetch | undefined {
|
): typeof fetch | undefined {
|
||||||
applyTelegramNetworkWorkarounds(options?.network);
|
applyTelegramNetworkWorkarounds(options?.network);
|
||||||
if (proxyFetch) {
|
const sourceFetch = proxyFetch ? resolveFetch(proxyFetch) : resolveFetch();
|
||||||
return resolveFetch(proxyFetch);
|
if (!sourceFetch) {
|
||||||
}
|
|
||||||
const fetchImpl = resolveFetch();
|
|
||||||
if (!fetchImpl) {
|
|
||||||
throw new Error("fetch is not available; set channels.telegram.proxy in config");
|
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 {
|
export function resetTelegramFetchStateForTests(): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user