Skip to content

Commit 6b1f485

Browse files
authored
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>
1 parent 5554fd2 commit 6b1f485

File tree

2 files changed

+163
-1
lines changed

2 files changed

+163
-1
lines changed

src/telegram/probe.test.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { type Mock, describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2+
import { probeTelegram } from "./probe.js";
3+
4+
describe("probeTelegram retry logic", () => {
5+
const token = "test-token";
6+
const timeoutMs = 5000;
7+
let fetchMock: Mock;
8+
9+
beforeEach(() => {
10+
vi.useFakeTimers();
11+
fetchMock = vi.fn();
12+
global.fetch = fetchMock;
13+
});
14+
15+
afterEach(() => {
16+
vi.useRealTimers();
17+
vi.restoreAllMocks();
18+
});
19+
20+
it("should succeed if the first attempt succeeds", async () => {
21+
const mockResponse = {
22+
ok: true,
23+
json: vi.fn().mockResolvedValue({
24+
ok: true,
25+
result: { id: 123, username: "test_bot" },
26+
}),
27+
};
28+
fetchMock.mockResolvedValueOnce(mockResponse);
29+
30+
// Mock getWebhookInfo which is also called
31+
fetchMock.mockResolvedValueOnce({
32+
ok: true,
33+
json: vi.fn().mockResolvedValue({ ok: true, result: { url: "" } }),
34+
});
35+
36+
const result = await probeTelegram(token, timeoutMs);
37+
38+
expect(result.ok).toBe(true);
39+
expect(fetchMock).toHaveBeenCalledTimes(2); // getMe + getWebhookInfo
40+
expect(result.bot?.username).toBe("test_bot");
41+
});
42+
43+
it("should retry and succeed if first attempt fails but second succeeds", async () => {
44+
// 1st attempt: Network error
45+
fetchMock.mockRejectedValueOnce(new Error("Network timeout"));
46+
47+
// 2nd attempt: Success
48+
fetchMock.mockResolvedValueOnce({
49+
ok: true,
50+
json: vi.fn().mockResolvedValue({
51+
ok: true,
52+
result: { id: 123, username: "test_bot" },
53+
}),
54+
});
55+
56+
// getWebhookInfo
57+
fetchMock.mockResolvedValueOnce({
58+
ok: true,
59+
json: vi.fn().mockResolvedValue({ ok: true, result: { url: "" } }),
60+
});
61+
62+
const probePromise = probeTelegram(token, timeoutMs);
63+
64+
// Fast-forward 1 second for the retry delay
65+
await vi.advanceTimersByTimeAsync(1000);
66+
67+
const result = await probePromise;
68+
69+
expect(result.ok).toBe(true);
70+
expect(fetchMock).toHaveBeenCalledTimes(3); // fail getMe, success getMe, getWebhookInfo
71+
expect(result.bot?.username).toBe("test_bot");
72+
});
73+
74+
it("should retry twice and succeed on the third attempt", async () => {
75+
// 1st attempt: Network error
76+
fetchMock.mockRejectedValueOnce(new Error("Network error 1"));
77+
// 2nd attempt: Network error
78+
fetchMock.mockRejectedValueOnce(new Error("Network error 2"));
79+
80+
// 3rd attempt: Success
81+
fetchMock.mockResolvedValueOnce({
82+
ok: true,
83+
json: vi.fn().mockResolvedValue({
84+
ok: true,
85+
result: { id: 123, username: "test_bot" },
86+
}),
87+
});
88+
89+
// getWebhookInfo
90+
fetchMock.mockResolvedValueOnce({
91+
ok: true,
92+
json: vi.fn().mockResolvedValue({ ok: true, result: { url: "" } }),
93+
});
94+
95+
const probePromise = probeTelegram(token, timeoutMs);
96+
97+
// Fast-forward for two retries
98+
await vi.advanceTimersByTimeAsync(1000);
99+
await vi.advanceTimersByTimeAsync(1000);
100+
101+
const result = await probePromise;
102+
103+
expect(result.ok).toBe(true);
104+
expect(fetchMock).toHaveBeenCalledTimes(4); // fail, fail, success, webhook
105+
expect(result.bot?.username).toBe("test_bot");
106+
});
107+
108+
it("should fail after 3 unsuccessful attempts", async () => {
109+
const errorMsg = "Final network error";
110+
fetchMock.mockRejectedValue(new Error(errorMsg));
111+
112+
const probePromise = probeTelegram(token, timeoutMs);
113+
114+
// Fast-forward for all retries
115+
await vi.advanceTimersByTimeAsync(1000);
116+
await vi.advanceTimersByTimeAsync(1000);
117+
118+
const result = await probePromise;
119+
120+
expect(result.ok).toBe(false);
121+
expect(result.error).toBe(errorMsg);
122+
expect(fetchMock).toHaveBeenCalledTimes(3); // 3 attempts at getMe
123+
});
124+
125+
it("should NOT retry if getMe returns a 401 Unauthorized", async () => {
126+
const mockResponse = {
127+
ok: false,
128+
status: 401,
129+
json: vi.fn().mockResolvedValue({
130+
ok: false,
131+
description: "Unauthorized",
132+
}),
133+
};
134+
fetchMock.mockResolvedValueOnce(mockResponse);
135+
136+
const result = await probeTelegram(token, timeoutMs);
137+
138+
expect(result.ok).toBe(false);
139+
expect(result.status).toBe(401);
140+
expect(result.error).toBe("Unauthorized");
141+
expect(fetchMock).toHaveBeenCalledTimes(1); // Should not retry
142+
});
143+
});

src/telegram/probe.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,26 @@ export async function probeTelegram(
3535
};
3636

3737
try {
38-
const meRes = await fetchWithTimeout(`${base}/getMe`, {}, timeoutMs, fetcher);
38+
let meRes: Response | null = null;
39+
let fetchError: unknown = null;
40+
41+
// Retry loop for initial connection (handles network/DNS startup races)
42+
for (let i = 0; i < 3; i++) {
43+
try {
44+
meRes = await fetchWithTimeout(`${base}/getMe`, {}, timeoutMs, fetcher);
45+
break;
46+
} catch (err) {
47+
fetchError = err;
48+
if (i < 2) {
49+
await new Promise((resolve) => setTimeout(resolve, 1000));
50+
}
51+
}
52+
}
53+
54+
if (!meRes) {
55+
throw fetchError;
56+
}
57+
3958
const meJson = (await meRes.json()) as {
4059
ok?: boolean;
4160
description?: string;

0 commit comments

Comments
 (0)