fix: add provider retry policy

This commit is contained in:
Peter Steinberger
2026-01-07 17:48:19 +00:00
parent 8db522d6a6
commit de55f4e111
15 changed files with 779 additions and 101 deletions

View File

@@ -80,6 +80,56 @@ describe("sendMessageTelegram", () => {
).rejects.toThrow(/chat_id=123/);
});
it("retries on transient errors with retry_after", async () => {
vi.useFakeTimers();
const chatId = "123";
const err = Object.assign(new Error("429"), {
parameters: { retry_after: 0.5 },
});
const sendMessage = vi
.fn()
.mockRejectedValueOnce(err)
.mockResolvedValueOnce({
message_id: 1,
chat: { id: chatId },
});
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
const promise = sendMessageTelegram(chatId, "hi", {
token: "tok",
api,
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 },
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toEqual({ messageId: "1", chatId });
expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(500);
setTimeoutSpy.mockRestore();
vi.useRealTimers();
});
it("does not retry on non-transient errors", async () => {
const chatId = "123";
const sendMessage = vi
.fn()
.mockRejectedValue(new Error("400: Bad Request"));
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
await expect(
sendMessageTelegram(chatId, "hi", {
token: "tok",
api,
retry: { attempts: 3, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
}),
).rejects.toThrow(/Bad Request/);
expect(sendMessage).toHaveBeenCalledTimes(1);
});
it("sends GIF media as animation", async () => {
const chatId = "123";
const sendAnimation = vi.fn().mockResolvedValue({

View File

@@ -1,9 +1,14 @@
// @ts-nocheck
import { Bot, InputFile } from "grammy";
import { loadConfig } from "../config/config.js";
import type { ClawdbotConfig } from "../config/types.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { RetryConfig } from "../infra/retry.js";
import { createTelegramRetryRunner } from "../infra/retry-policy.js";
import { mediaKindFromMime } from "../media/constants.js";
import { isGifMedia } from "../media/mime.js";
import { loadWebMedia } from "../web/media.js";
import { resolveTelegramToken } from "./token.js";
type TelegramSendOpts = {
token?: string;
@@ -12,6 +17,7 @@ type TelegramSendOpts = {
maxBytes?: number;
messageThreadId?: number;
api?: Bot["api"];
retry?: RetryConfig;
};
type TelegramSendResult = {
@@ -23,16 +29,19 @@ type TelegramReactionOpts = {
token?: string;
api?: Bot["api"];
remove?: boolean;
verbose?: boolean;
retry?: RetryConfig;
};
const PARSE_ERR_RE =
/can't parse entities|parse entities|find end of the entity/i;
function resolveToken(explicit?: string): string {
const token = explicit ?? process.env.TELEGRAM_BOT_TOKEN;
function resolveToken(explicit?: string, cfg?: ClawdbotConfig): string {
if (explicit?.trim()) return explicit.trim();
const { token } = resolveTelegramToken(cfg);
if (!token) {
throw new Error(
"TELEGRAM_BOT_TOKEN is required for Telegram sends (Bot API)",
"TELEGRAM_BOT_TOKEN (or telegram.botToken/tokenFile) is required for Telegram sends (Bot API)",
);
}
return token.trim();
@@ -84,7 +93,8 @@ export async function sendMessageTelegram(
text: string,
opts: TelegramSendOpts = {},
): Promise<TelegramSendResult> {
const token = resolveToken(opts.token);
const cfg = loadConfig();
const token = resolveToken(opts.token, cfg);
const chatId = normalizeChatId(to);
const bot = opts.api ? null : new Bot(token);
const api = opts.api ?? bot?.api;
@@ -93,34 +103,11 @@ export async function sendMessageTelegram(
typeof opts.messageThreadId === "number"
? { message_thread_id: Math.trunc(opts.messageThreadId) }
: undefined;
const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
const sendWithRetry = async <T>(fn: () => Promise<T>, label: string) => {
let lastErr: unknown;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
return await fn();
} catch (err) {
lastErr = err;
const errText = formatErrorMessage(err);
const terminal =
attempt === 3 ||
!/429|timeout|connect|reset|closed|unavailable|temporarily/i.test(
errText,
);
if (terminal) break;
const backoff = 400 * attempt;
if (opts.verbose) {
console.warn(
`telegram send retry ${attempt}/2 for ${label} in ${backoff}ms: ${errText}`,
);
}
await sleep(backoff);
}
}
throw lastErr ?? new Error(`Telegram send failed (${label})`);
};
const request = createTelegramRetryRunner({
retry: opts.retry,
configRetry: cfg.telegram?.retry,
verbose: opts.verbose,
});
const wrapChatNotFound = (err: unknown) => {
if (!/400: Bad Request: chat not found/i.test(formatErrorMessage(err)))
@@ -154,35 +141,35 @@ export async function sendMessageTelegram(
| Awaited<ReturnType<typeof api.sendAnimation>>
| Awaited<ReturnType<typeof api.sendDocument>>;
if (isGif) {
result = await sendWithRetry(
result = await request(
() => api.sendAnimation(chatId, file, { caption, ...threadParams }),
"animation",
).catch((err) => {
throw wrapChatNotFound(err);
});
} else if (kind === "image") {
result = await sendWithRetry(
result = await request(
() => api.sendPhoto(chatId, file, { caption, ...threadParams }),
"photo",
).catch((err) => {
throw wrapChatNotFound(err);
});
} else if (kind === "video") {
result = await sendWithRetry(
result = await request(
() => api.sendVideo(chatId, file, { caption, ...threadParams }),
"video",
).catch((err) => {
throw wrapChatNotFound(err);
});
} else if (kind === "audio") {
result = await sendWithRetry(
result = await request(
() => api.sendAudio(chatId, file, { caption, ...threadParams }),
"audio",
).catch((err) => {
throw wrapChatNotFound(err);
});
} else {
result = await sendWithRetry(
result = await request(
() => api.sendDocument(chatId, file, { caption, ...threadParams }),
"document",
).catch((err) => {
@@ -196,7 +183,7 @@ export async function sendMessageTelegram(
if (!text || !text.trim()) {
throw new Error("Message must be non-empty for Telegram sends");
}
const res = await sendWithRetry(
const res = await request(
() =>
api.sendMessage(chatId, text, {
parse_mode: "Markdown",
@@ -213,7 +200,7 @@ export async function sendMessageTelegram(
`telegram markdown parse failed, retrying as plain text: ${errText}`,
);
}
return await sendWithRetry(
return await request(
() =>
threadParams
? api.sendMessage(chatId, text, threadParams)
@@ -235,11 +222,17 @@ export async function reactMessageTelegram(
emoji: string,
opts: TelegramReactionOpts = {},
): Promise<{ ok: true }> {
const token = resolveToken(opts.token);
const cfg = loadConfig();
const token = resolveToken(opts.token, cfg);
const chatId = normalizeChatId(String(chatIdInput));
const messageId = normalizeMessageId(messageIdInput);
const bot = opts.api ? null : new Bot(token);
const api = opts.api ?? bot?.api;
const request = createTelegramRetryRunner({
retry: opts.retry,
configRetry: cfg.telegram?.retry,
verbose: opts.verbose,
});
const remove = opts.remove === true;
const trimmedEmoji = emoji.trim();
const reactions =
@@ -247,7 +240,10 @@ export async function reactMessageTelegram(
if (typeof api.setMessageReaction !== "function") {
throw new Error("Telegram reactions are unavailable in this bot API.");
}
await api.setMessageReaction(chatId, messageId, reactions);
await request(
() => api.setMessageReaction(chatId, messageId, reactions),
"reaction",
);
return { ok: true };
}