import { describe, expect, it } from "vitest"; import { isRecoverableTelegramNetworkError, isSafeToRetrySendError } from "./network-errors.js"; describe("isRecoverableTelegramNetworkError", () => { 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); }); });