import { describe, expect, it } from "vitest"; import { getTelegramNetworkErrorOrigin, isRecoverableTelegramNetworkError, isSafeToRetrySendError, isTelegramClientRejection, isTelegramPollingNetworkError, isTelegramServerError, tagTelegramNetworkError, } from "./network-errors.js"; describe("isRecoverableTelegramNetworkError", () => { it("tracks Telegram polling origin separately from generic network matching", () => { const slackDnsError = Object.assign( new Error("A request error occurred: getaddrinfo ENOTFOUND slack.com"), { code: "ENOTFOUND", hostname: "slack.com", }, ); expect(isRecoverableTelegramNetworkError(slackDnsError)).toBe(true); expect(isTelegramPollingNetworkError(slackDnsError)).toBe(false); tagTelegramNetworkError(slackDnsError, { method: "getUpdates", url: "https://api.telegram.org/bot123456:ABC/getUpdates", }); expect(getTelegramNetworkErrorOrigin(slackDnsError)).toEqual({ method: "getupdates", url: "https://api.telegram.org/bot123456:ABC/getUpdates", }); expect(isTelegramPollingNetworkError(slackDnsError)).toBe(true); }); it("detects recoverable error codes", () => { const err = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }); expect(isRecoverableTelegramNetworkError(err)).toBe(true); }); it("detects additional recoverable error codes", () => { const aborted = Object.assign(new Error("aborted"), { code: "ECONNABORTED" }); const network = Object.assign(new Error("network"), { code: "ERR_NETWORK" }); expect(isRecoverableTelegramNetworkError(aborted)).toBe(true); expect(isRecoverableTelegramNetworkError(network)).toBe(true); }); it("detects AbortError names", () => { const err = Object.assign(new Error("The operation was aborted"), { name: "AbortError" }); expect(isRecoverableTelegramNetworkError(err)).toBe(true); }); it("detects nested causes", () => { const cause = Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }); const err = Object.assign(new TypeError("fetch failed"), { cause }); expect(isRecoverableTelegramNetworkError(err)).toBe(true); }); it("detects expanded message patterns", () => { expect(isRecoverableTelegramNetworkError(new Error("TypeError: fetch failed"))).toBe(true); expect(isRecoverableTelegramNetworkError(new Error("Undici: socket failure"))).toBe(true); }); it("treats undici fetch failed errors as recoverable in send context", () => { const err = new TypeError("fetch failed"); 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("treats grammY failed-after envelope errors as recoverable in send context", () => { 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); }); it("detects grammY 'timed out' long-poll errors (#7239)", () => { const err = new Error("Request to 'getUpdates' timed out after 500 seconds"); expect(isRecoverableTelegramNetworkError(err)).toBe(true); }); // Grammy HttpError tests (issue #3815) // Grammy wraps fetch errors in .error property, not .cause describe("Grammy HttpError", () => { class MockHttpError extends Error { constructor( message: string, public readonly error: unknown, ) { super(message); this.name = "HttpError"; } } it("detects network error wrapped in HttpError", () => { const fetchError = new TypeError("fetch failed"); const httpError = new MockHttpError( "Network request for 'setMyCommands' failed!", fetchError, ); expect(isRecoverableTelegramNetworkError(httpError)).toBe(true); }); it("detects network error with cause wrapped in HttpError", () => { const cause = Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }); const fetchError = Object.assign(new TypeError("fetch failed"), { cause }); const httpError = new MockHttpError("Network request for 'getUpdates' failed!", fetchError); expect(isRecoverableTelegramNetworkError(httpError)).toBe(true); }); it("returns false for non-network errors wrapped in HttpError", () => { const authError = new Error("Unauthorized: bot token is invalid"); const httpError = new MockHttpError("Bad Request: invalid token", authError); expect(isRecoverableTelegramNetworkError(httpError)).toBe(false); }); }); }); describe("isSafeToRetrySendError", () => { it("allows retry for ECONNREFUSED (pre-connect, message not sent)", () => { const err = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }); expect(isSafeToRetrySendError(err)).toBe(true); }); it("allows retry for ENOTFOUND (DNS failure, message not sent)", () => { const err = Object.assign(new Error("getaddrinfo ENOTFOUND"), { code: "ENOTFOUND" }); expect(isSafeToRetrySendError(err)).toBe(true); }); it("allows retry for EAI_AGAIN (transient DNS, message not sent)", () => { const err = Object.assign(new Error("getaddrinfo EAI_AGAIN"), { code: "EAI_AGAIN" }); expect(isSafeToRetrySendError(err)).toBe(true); }); it("allows retry for ENETUNREACH (no route to host, message not sent)", () => { const err = Object.assign(new Error("connect ENETUNREACH"), { code: "ENETUNREACH" }); expect(isSafeToRetrySendError(err)).toBe(true); }); it("allows retry for EHOSTUNREACH (host unreachable, message not sent)", () => { const err = Object.assign(new Error("connect EHOSTUNREACH"), { code: "EHOSTUNREACH" }); expect(isSafeToRetrySendError(err)).toBe(true); }); it("does NOT allow retry for ECONNRESET (message may already be delivered)", () => { const err = Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }); expect(isSafeToRetrySendError(err)).toBe(false); }); it("does NOT allow retry for ETIMEDOUT (message may already be delivered)", () => { const err = Object.assign(new Error("connect ETIMEDOUT"), { code: "ETIMEDOUT" }); expect(isSafeToRetrySendError(err)).toBe(false); }); it("does NOT allow retry for EPIPE (connection broken mid-transfer, message may be delivered)", () => { const err = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); expect(isSafeToRetrySendError(err)).toBe(false); }); it("does NOT allow retry for UND_ERR_CONNECT_TIMEOUT (ambiguous timing)", () => { const err = Object.assign(new Error("connect timeout"), { code: "UND_ERR_CONNECT_TIMEOUT" }); expect(isSafeToRetrySendError(err)).toBe(false); }); it("does NOT allow retry for non-network errors", () => { expect(isSafeToRetrySendError(new Error("400: Bad Request"))).toBe(false); expect(isSafeToRetrySendError(null)).toBe(false); }); it("detects pre-connect error nested in cause chain", () => { const root = Object.assign(new Error("ECONNREFUSED"), { code: "ECONNREFUSED" }); const wrapped = Object.assign(new Error("fetch failed"), { cause: root }); expect(isSafeToRetrySendError(wrapped)).toBe(true); }); }); describe("isTelegramServerError", () => { it("returns true for error_code 500", () => { const err = Object.assign(new Error("Internal Server Error"), { error_code: 500 }); expect(isTelegramServerError(err)).toBe(true); }); it("returns true for error_code 502", () => { const err = Object.assign(new Error("Bad Gateway"), { error_code: 502 }); expect(isTelegramServerError(err)).toBe(true); }); it("returns false for error_code 403", () => { const err = Object.assign(new Error("Forbidden"), { error_code: 403 }); expect(isTelegramServerError(err)).toBe(false); }); it("returns false for plain Error", () => { expect(isTelegramServerError(new Error("500: Internal Server Error"))).toBe(false); }); }); describe("isTelegramClientRejection", () => { it("returns true for error_code 400", () => { const err = Object.assign(new Error("Bad Request"), { error_code: 400 }); expect(isTelegramClientRejection(err)).toBe(true); }); it("returns true for error_code 403", () => { const err = Object.assign(new Error("Forbidden"), { error_code: 403 }); expect(isTelegramClientRejection(err)).toBe(true); }); it("returns false for error_code 502", () => { const err = Object.assign(new Error("Bad Gateway"), { error_code: 502 }); expect(isTelegramClientRejection(err)).toBe(false); }); it("returns false for plain Error", () => { expect(isTelegramClientRejection(new Error("400: Bad Request"))).toBe(false); }); it("detects error_code in nested cause", () => { const inner = Object.assign(new Error("Forbidden"), { error_code: 403 }); const outer = Object.assign(new Error("wrapped"), { cause: inner }); expect(isTelegramClientRejection(outer)).toBe(true); }); });