From 6b1f485ce8764ab6368f0506866a2a0012852c85 Mon Sep 17 00:00:00 2001 From: Jake Date: Fri, 13 Feb 2026 04:11:35 +1300 Subject: [PATCH] fix(telegram): add retry logic to health probe (openclaw#7405) thanks @mcinteerj Verified: - CI=true pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test Co-authored-by: mcinteerj <3613653+mcinteerj@users.noreply.github.com> --- src/telegram/probe.test.ts | 143 +++++++++++++++++++++++++++++++++++++ src/telegram/probe.ts | 21 +++++- 2 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 src/telegram/probe.test.ts diff --git a/src/telegram/probe.test.ts b/src/telegram/probe.test.ts new file mode 100644 index 00000000000..d35270f7659 --- /dev/null +++ b/src/telegram/probe.test.ts @@ -0,0 +1,143 @@ +import { type Mock, describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { probeTelegram } from "./probe.js"; + +describe("probeTelegram retry logic", () => { + const token = "test-token"; + const timeoutMs = 5000; + let fetchMock: Mock; + + beforeEach(() => { + vi.useFakeTimers(); + fetchMock = vi.fn(); + global.fetch = fetchMock; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("should succeed if the first attempt succeeds", async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + ok: true, + result: { id: 123, username: "test_bot" }, + }), + }; + fetchMock.mockResolvedValueOnce(mockResponse); + + // Mock getWebhookInfo which is also called + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue({ ok: true, result: { url: "" } }), + }); + + const result = await probeTelegram(token, timeoutMs); + + expect(result.ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); // getMe + getWebhookInfo + expect(result.bot?.username).toBe("test_bot"); + }); + + it("should retry and succeed if first attempt fails but second succeeds", async () => { + // 1st attempt: Network error + fetchMock.mockRejectedValueOnce(new Error("Network timeout")); + + // 2nd attempt: Success + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue({ + ok: true, + result: { id: 123, username: "test_bot" }, + }), + }); + + // getWebhookInfo + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue({ ok: true, result: { url: "" } }), + }); + + const probePromise = probeTelegram(token, timeoutMs); + + // Fast-forward 1 second for the retry delay + await vi.advanceTimersByTimeAsync(1000); + + const result = await probePromise; + + expect(result.ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(3); // fail getMe, success getMe, getWebhookInfo + expect(result.bot?.username).toBe("test_bot"); + }); + + it("should retry twice and succeed on the third attempt", async () => { + // 1st attempt: Network error + fetchMock.mockRejectedValueOnce(new Error("Network error 1")); + // 2nd attempt: Network error + fetchMock.mockRejectedValueOnce(new Error("Network error 2")); + + // 3rd attempt: Success + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue({ + ok: true, + result: { id: 123, username: "test_bot" }, + }), + }); + + // getWebhookInfo + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue({ ok: true, result: { url: "" } }), + }); + + const probePromise = probeTelegram(token, timeoutMs); + + // Fast-forward for two retries + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + + const result = await probePromise; + + expect(result.ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(4); // fail, fail, success, webhook + expect(result.bot?.username).toBe("test_bot"); + }); + + it("should fail after 3 unsuccessful attempts", async () => { + const errorMsg = "Final network error"; + fetchMock.mockRejectedValue(new Error(errorMsg)); + + const probePromise = probeTelegram(token, timeoutMs); + + // Fast-forward for all retries + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + + const result = await probePromise; + + expect(result.ok).toBe(false); + expect(result.error).toBe(errorMsg); + expect(fetchMock).toHaveBeenCalledTimes(3); // 3 attempts at getMe + }); + + it("should NOT retry if getMe returns a 401 Unauthorized", async () => { + const mockResponse = { + ok: false, + status: 401, + json: vi.fn().mockResolvedValue({ + ok: false, + description: "Unauthorized", + }), + }; + fetchMock.mockResolvedValueOnce(mockResponse); + + const result = await probeTelegram(token, timeoutMs); + + expect(result.ok).toBe(false); + expect(result.status).toBe(401); + expect(result.error).toBe("Unauthorized"); + expect(fetchMock).toHaveBeenCalledTimes(1); // Should not retry + }); +}); diff --git a/src/telegram/probe.ts b/src/telegram/probe.ts index 272a110dcd4..c4d4001852c 100644 --- a/src/telegram/probe.ts +++ b/src/telegram/probe.ts @@ -35,7 +35,26 @@ export async function probeTelegram( }; try { - const meRes = await fetchWithTimeout(`${base}/getMe`, {}, timeoutMs, fetcher); + let meRes: Response | null = null; + let fetchError: unknown = null; + + // Retry loop for initial connection (handles network/DNS startup races) + for (let i = 0; i < 3; i++) { + try { + meRes = await fetchWithTimeout(`${base}/getMe`, {}, timeoutMs, fetcher); + break; + } catch (err) { + fetchError = err; + if (i < 2) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + } + + if (!meRes) { + throw fetchError; + } + const meJson = (await meRes.json()) as { ok?: boolean; description?: string;