From 273932850868116a16fb89b3aa9fa9e2d69b6eaf Mon Sep 17 00:00:00 2001 From: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Date: Sun, 22 Feb 2026 06:46:11 -0400 Subject: [PATCH] fix(telegram): classify undici fetch errors as recoverable for retry (#16699) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 67b5bce44f7014c8cbefc00eed0731e61d6300b9 Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + docs/channels/telegram.md | 19 +++++++++++++++++++ src/telegram/monitor.test.ts | 8 ++++++-- src/telegram/network-errors.test.ts | 17 +++++++++++++++-- src/telegram/network-errors.ts | 13 ++++++++----- 5 files changed, 49 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ad0412d..ea5223314 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Plugins/CLI: make `openclaw plugins enable` and plugin install/link flows update allowlists via shared plugin-enable policy so enabled plugins are not left disabled by allowlist mismatch. (#23190) Thanks @downwind7clawd-ctrl. - Memory/QMD: add optional `memory.qmd.mcporter` search routing so QMD `query/search/vsearch` can run through mcporter keep-alive flows (including multi-collection paths) to reduce cold starts, while keeping searches on agent-scoped QMD state for consistent recall. (#19617) Thanks @nicole-luxe and @vignesh07. - Chat/UI: strip inline reply/audio directive tags (`[[reply_to_current]]`, `[[reply_to:]]`, `[[audio_as_voice]]`) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces. +- Telegram/Retry: classify undici `TypeError: fetch failed` as recoverable in both polling and send retry paths so transient fetch failures no longer fail fast. (#16699) thanks @Glucksberg. - BlueBubbles/DM history: restore DM backfill context with account-scoped rolling history, bounded backfill retries, and safer history payload limits. (#20302) Thanks @Ryan-Haines. - BlueBubbles/Webhooks: accept inbound/reaction webhook payloads when BlueBubbles omits `handle` but provides DM `chatGuid`, and harden payload extraction for array/string-wrapped message bodies so valid webhook events no longer get rejected as unparseable. (#23275) Thanks @toph31. - Security/Audit: add `openclaw security audit` finding `gateway.nodes.allow_commands_dangerous` for risky `gateway.nodes.allowCommands` overrides, with severity upgraded to critical on remote gateway exposure. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 8676bce4e..3867224fc 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -670,6 +670,25 @@ openclaw message send --channel telegram --target @name --message "hi" - Node 22+ + custom fetch/proxy can trigger immediate abort behavior if AbortSignal types mismatch. - Some hosts resolve `api.telegram.org` to IPv6 first; broken IPv6 egress can cause intermittent Telegram API failures. + - If logs include `TypeError: fetch failed` or `Network request for 'getUpdates' failed!`, OpenClaw now retries these as recoverable network errors. + - On VPS hosts with unstable direct egress/TLS, route Telegram API calls through `channels.telegram.proxy`: + +```yaml +channels: + telegram: + proxy: socks5://user:pass@proxy-host:1080 +``` + + - If DNS/IPv6 selection is unstable, force Node family selection behavior explicitly: + +```yaml +channels: + telegram: + network: + autoSelectFamily: false +``` + + - Environment override (temporary): set `OPENCLAW_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY=1`. - Validate DNS answers: ```bash diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts index 7c836e1b4..ff12faaa2 100644 --- a/src/telegram/monitor.test.ts +++ b/src/telegram/monitor.test.ts @@ -169,8 +169,12 @@ describe("monitorTelegramProvider (grammY)", () => { expect(api.sendMessage).not.toHaveBeenCalled(); }); - it("retries on recoverable network errors", async () => { - const networkError = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }); + it("retries on recoverable undici fetch errors", async () => { + const networkError = Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect timeout"), { + code: "UND_ERR_CONNECT_TIMEOUT", + }), + }); runSpy .mockImplementationOnce(() => ({ task: () => Promise.reject(networkError), diff --git a/src/telegram/network-errors.test.ts b/src/telegram/network-errors.test.ts index c435320bd..b92081a82 100644 --- a/src/telegram/network-errors.test.ts +++ b/src/telegram/network-errors.test.ts @@ -30,12 +30,25 @@ describe("isRecoverableTelegramNetworkError", () => { expect(isRecoverableTelegramNetworkError(new Error("Undici: socket failure"))).toBe(true); }); - it("skips message matches for send context", () => { + it("treats undici fetch failed errors as recoverable in send context", () => { const err = new TypeError("fetch failed"); - expect(isRecoverableTelegramNetworkError(err, { context: "send" })).toBe(false); + expect(isRecoverableTelegramNetworkError(err, { context: "send" })).toBe(true); + expect( + isRecoverableTelegramNetworkError(new Error("TypeError: fetch failed"), { context: "send" }), + ).toBe(true); expect(isRecoverableTelegramNetworkError(err, { context: "polling" })).toBe(true); }); + it("skips broad message matches for send context", () => { + const networkRequestErr = new Error("Network request for 'sendMessage' failed!"); + expect(isRecoverableTelegramNetworkError(networkRequestErr, { context: "send" })).toBe(false); + expect(isRecoverableTelegramNetworkError(networkRequestErr, { context: "polling" })).toBe(true); + + const undiciSnippetErr = new Error("Undici: socket failure"); + expect(isRecoverableTelegramNetworkError(undiciSnippetErr, { context: "send" })).toBe(false); + expect(isRecoverableTelegramNetworkError(undiciSnippetErr, { context: "polling" })).toBe(true); + }); + it("returns false for unrelated errors", () => { expect(isRecoverableTelegramNetworkError(new Error("invalid token"))).toBe(false); }); diff --git a/src/telegram/network-errors.ts b/src/telegram/network-errors.ts index 75c22ea7f..177ef00d6 100644 --- a/src/telegram/network-errors.ts +++ b/src/telegram/network-errors.ts @@ -27,9 +27,9 @@ const RECOVERABLE_ERROR_NAMES = new Set([ "BodyTimeoutError", ]); +const ALWAYS_RECOVERABLE_MESSAGES = new Set(["fetch failed", "typeerror: fetch failed"]); + const RECOVERABLE_MESSAGE_SNIPPETS = [ - "fetch failed", - "typeerror: fetch failed", "undici", "network error", "network request", @@ -138,9 +138,12 @@ export function isRecoverableTelegramNetworkError( return true; } - if (allowMessageMatch) { - const message = formatErrorMessage(candidate).toLowerCase(); - if (message && RECOVERABLE_MESSAGE_SNIPPETS.some((snippet) => message.includes(snippet))) { + const message = formatErrorMessage(candidate).trim().toLowerCase(); + if (message && ALWAYS_RECOVERABLE_MESSAGES.has(message)) { + return true; + } + if (allowMessageMatch && message) { + if (RECOVERABLE_MESSAGE_SNIPPETS.some((snippet) => message.includes(snippet))) { return true; } }