diff --git a/extensions/telegram/src/fetch.test.ts b/extensions/telegram/src/fetch.test.ts index fac7de6865c2..15dc6dfd9a98 100644 --- a/extensions/telegram/src/fetch.test.ts +++ b/extensions/telegram/src/fetch.test.ts @@ -957,7 +957,7 @@ describe("resolveTelegramFetch", () => { expect(eighthDispatcher).toBe(firstDispatcher); expect(ninthDispatcher).toBe(firstDispatcher); expectPinnedFallbackIpDispatcher(3); - expectLoggerMessageContaining(loggerWarn, "fetch fallback: DNS-resolved IP unreachable"); + expectLoggerMessageContaining(loggerWarn, "fetch fallback: primary connection path failed"); expectLoggerMessageContaining( loggerDebug, "fetch fallback: recovered from attempt 2 to attempt 0", @@ -1193,6 +1193,31 @@ describe("resolveTelegramFetch", () => { expect(undiciFetch).toHaveBeenCalledTimes(1); }); + it("does not automatically retry structured EADDRNOTAVAIL fetch failures", async () => { + const fetchError = buildFetchFallbackError("EADDRNOTAVAIL"); + undiciFetch.mockRejectedValue(fetchError); + + const resolved = resolveTelegramFetchOrThrow(undefined, STICKY_IPV4_FALLBACK_NETWORK); + + await expect(resolved("https://api.telegram.org/botx/sendMessage")).rejects.toThrow( + "fetch failed", + ); + + expect(undiciFetch).toHaveBeenCalledTimes(1); + }); + + it("preserves EADDRNOTAVAIL in forced fallback diagnostics", () => { + const transport = resolveTelegramTransport(undefined, STICKY_IPV4_FALLBACK_NETWORK); + const fetchError = buildFetchFallbackError("EADDRNOTAVAIL"); + + expect(transport.forceFallback?.("probe timeout/network error", fetchError)).toBe(true); + expect(transport.forceFallback?.("probe timeout/network error", fetchError)).toBe(true); + + expectLoggerMessageContaining(loggerWarn, "primary connection path failed"); + expectLoggerMessageContaining(loggerWarn, "codes=EADDRNOTAVAIL"); + expectNoLoggerMessageContaining(loggerWarn, "DNS-resolved IP unreachable"); + }); + it("retries sticky fallback when the local network is down during connect", async () => { undiciFetch .mockRejectedValueOnce(buildFetchFallbackError("ENETDOWN")) diff --git a/extensions/telegram/src/fetch.ts b/extensions/telegram/src/fetch.ts index 730ceb7637c8..107ae637fac8 100644 --- a/extensions/telegram/src/fetch.ts +++ b/extensions/telegram/src/fetch.ts @@ -488,9 +488,10 @@ export type TelegramTransport = { dispatcherAttempts?: TelegramDispatcherAttempt[]; /** * Promote this transport to its next fallback dispatcher before the next - * request. Returns false when no fallback path exists. + * request. The original error, when available, is retained in diagnostics. + * Returns false when no fallback path exists. */ - forceFallback?: (reason: string) => boolean; + forceFallback?: (reason: string, err?: unknown) => boolean; /** * Release all dispatchers owned by this transport and the TCP sockets they * hold. Safe to call multiple times; subsequent calls resolve immediately. @@ -563,7 +564,8 @@ function createTelegramTransportAttempts(params: { }, exportAttempt: { dispatcherPolicy: fallbackIpPolicy }, logLevel: "warn", - logMessage: "fetch fallback: DNS-resolved IP unreachable; trying alternative Telegram API IP", + logMessage: + "fetch fallback: primary connection path failed; trying alternative Telegram API IP", }); return attempts; @@ -864,8 +866,8 @@ export function resolveTelegramTransport( fetch: resolvedFetch, sourceFetch, dispatcherAttempts: transportAttempts.map((attempt) => attempt.exportAttempt), - forceFallback: (reason: string) => - promoteStickyAttempt(stickyAttemptIndex + 1, new Error("forced fallback"), reason), + forceFallback: (reason: string, err?: unknown) => + promoteStickyAttempt(stickyAttemptIndex + 1, err ?? new Error("forced fallback"), reason), close, }; } diff --git a/extensions/telegram/src/probe.test.ts b/extensions/telegram/src/probe.test.ts index 6914b8e90fb7..b719e78aed1d 100644 --- a/extensions/telegram/src/probe.test.ts +++ b/extensions/telegram/src/probe.test.ts @@ -362,7 +362,7 @@ describe("probeTelegram retry logic", () => { const result = await probePromise; expect(result.ok).toBe(true); - expect(localForceFallback).toHaveBeenCalledWith("probe timeout/network error"); + expect(localForceFallback).toHaveBeenCalledWith("probe timeout/network error", timeoutError); expect(fetchMock).toHaveBeenCalledTimes(3); // 1 failed + 1 getMe success + 1 webhook } finally { vi.useRealTimers(); diff --git a/extensions/telegram/src/probe.ts b/extensions/telegram/src/probe.ts index 46d88cf2bc46..c302b6eee146 100644 --- a/extensions/telegram/src/probe.ts +++ b/extensions/telegram/src/probe.ts @@ -162,7 +162,8 @@ export async function probeTelegram( // On timeout or network error, promote the transport to its IPv4 // fallback dispatcher so the next retry (and all future probes // sharing this cached transport) skip the stalled IPv6 path. - transport.forceFallback?.("probe timeout/network error"); + // Keep the original socket code in transport fallback diagnostics. + transport.forceFallback?.("probe timeout/network error", err); if (i < 2) { const remainingAfterAttemptMs = resolveRemainingBudgetMs(); if (remainingAfterAttemptMs <= 0) {