diff --git a/src/telegram/network-errors.test.ts b/src/telegram/network-errors.test.ts index b92081a8284..c7206305b04 100644 --- a/src/telegram/network-errors.test.ts +++ b/src/telegram/network-errors.test.ts @@ -40,7 +40,7 @@ describe("isRecoverableTelegramNetworkError", () => { }); it("skips broad message matches for send context", () => { - const networkRequestErr = new Error("Network request for 'sendMessage' failed!"); + const networkRequestErr = new Error("Network request for 'sendMessage' timed out!"); expect(isRecoverableTelegramNetworkError(networkRequestErr, { context: "send" })).toBe(false); expect(isRecoverableTelegramNetworkError(networkRequestErr, { context: "polling" })).toBe(true); @@ -49,6 +49,20 @@ describe("isRecoverableTelegramNetworkError", () => { expect(isRecoverableTelegramNetworkError(undiciSnippetErr, { context: "polling" })).toBe(true); }); + it("treats grammY network envelope errors as recoverable in send context", () => { + expect( + isRecoverableTelegramNetworkError(new Error("Network request for 'sendMessage' failed!"), { + context: "send", + }), + ).toBe(true); + expect( + isRecoverableTelegramNetworkError( + new Error("Network request for 'sendMessage' failed after 2 attempts."), + { context: "send" }, + ), + ).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 f9b7061dd61..7413a20e9d2 100644 --- a/src/telegram/network-errors.ts +++ b/src/telegram/network-errors.ts @@ -33,6 +33,8 @@ const RECOVERABLE_ERROR_NAMES = new Set([ ]); const ALWAYS_RECOVERABLE_MESSAGES = new Set(["fetch failed", "typeerror: fetch failed"]); +const GRAMMY_NETWORK_REQUEST_FAILED_RE = + /^network request(?:\s+for\s+["']?[^"']+["']?)?\s+failed(?:\s+after\b.*)?[!.]?$/i; const RECOVERABLE_MESSAGE_SNIPPETS = [ "undici", @@ -106,6 +108,9 @@ export function isRecoverableTelegramNetworkError( if (message && ALWAYS_RECOVERABLE_MESSAGES.has(message)) { return true; } + if (message && GRAMMY_NETWORK_REQUEST_FAILED_RE.test(message)) { + return true; + } if (allowMessageMatch && message) { if (RECOVERABLE_MESSAGE_SNIPPETS.some((snippet) => message.includes(snippet))) { return true; diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index 59c98ea3a96..8f73753c70d 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -779,6 +779,29 @@ describe("sendMessageTelegram", () => { expect(sendMessage).toHaveBeenCalledTimes(1); }); + it("retries when grammY network envelope message includes failed-after wording", async () => { + const chatId = "123"; + const sendMessage = vi + .fn() + .mockRejectedValueOnce(new Error("Network request for 'sendMessage' failed after 1 attempts.")) + .mockResolvedValueOnce({ + message_id: 7, + chat: { id: chatId }, + }); + const api = { sendMessage } as unknown as { + sendMessage: typeof sendMessage; + }; + + const result = await sendMessageTelegram(chatId, "hi", { + token: "tok", + api, + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }); + + expect(sendMessage).toHaveBeenCalledTimes(2); + expect(result).toEqual({ messageId: "7", chatId }); + }); + it("sends GIF media as animation", async () => { const chatId = "123"; const sendAnimation = vi.fn().mockResolvedValue({