From 01b37f1d3248029da8589e1e6559c76f2d38ac03 Mon Sep 17 00:00:00 2001 From: Brandon Wise Date: Mon, 16 Feb 2026 15:11:48 -0500 Subject: [PATCH] fix(telegram): handle large file getFile errors gracefully Catch GrammyError when getFile fails for files >20MB (Telegram Bot API limit). Log warning, skip attachment, but continue processing message text. - Add FILE_TOO_BIG_RE regex to detect 'file is too big' errors - Add isFileTooBigError() and isRetryableGetFileError() helpers - Skip retrying permanent 400 errors (they'll fail every time) - Log specific warning for file size limit errors - Return null so message text is still processed Fixes #18518 --- .../bot/delivery.resolve-media-retry.test.ts | 65 +++++++++++++++++++ src/telegram/bot/delivery.ts | 38 ++++++++++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/telegram/bot/delivery.resolve-media-retry.test.ts b/src/telegram/bot/delivery.resolve-media-retry.test.ts index 79ab06bdc44..82997b369a1 100644 --- a/src/telegram/bot/delivery.resolve-media-retry.test.ts +++ b/src/telegram/bot/delivery.resolve-media-retry.test.ts @@ -15,6 +15,7 @@ vi.mock("../../media/fetch.js", () => ({ vi.mock("../../globals.js", () => ({ danger: (s: string) => s, + warn: (s: string) => s, logVerbose: () => {}, })); @@ -134,4 +135,68 @@ describe("resolveMedia getFile retry", () => { expect(getFile).toHaveBeenCalledTimes(3); expect(result).toBeNull(); }); + + it("does not retry 'file is too big' error (400 Bad Request) and returns null", async () => { + // Simulate Telegram Bot API error when file exceeds 20MB limit + const fileTooBigError = new Error( + "GrammyError: Call to 'getFile' failed! (400: Bad Request: file is too big)", + ); + const getFile = vi.fn().mockRejectedValue(fileTooBigError); + + const result = await resolveMedia(makeCtx("video", getFile), 10_000_000, "tok123"); + + // Should NOT retry - "file is too big" is a permanent error, not transient + expect(getFile).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); + }); + + it("returns null for audio when file is too big", async () => { + const fileTooBigError = new Error( + "GrammyError: Call to 'getFile' failed! (400: Bad Request: file is too big)", + ); + const getFile = vi.fn().mockRejectedValue(fileTooBigError); + + const result = await resolveMedia(makeCtx("audio", getFile), 10_000_000, "tok123"); + + expect(getFile).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); + }); + + it("returns null for voice when file is too big", async () => { + const fileTooBigError = new Error( + "GrammyError: Call to 'getFile' failed! (400: Bad Request: file is too big)", + ); + const getFile = vi.fn().mockRejectedValue(fileTooBigError); + + const result = await resolveMedia(makeCtx("voice", getFile), 10_000_000, "tok123"); + + expect(getFile).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); + }); + + it("still retries transient errors even after encountering file too big in different call", async () => { + // First call with transient error should retry + const getFile = vi + .fn() + .mockRejectedValueOnce(new Error("Network request for 'getFile' failed!")) + .mockResolvedValueOnce({ file_path: "voice/file_0.oga" }); + + fetchRemoteMedia.mockResolvedValueOnce({ + buffer: Buffer.from("audio"), + contentType: "audio/ogg", + fileName: "file_0.oga", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/file_0.oga", + contentType: "audio/ogg", + }); + + const promise = resolveMedia(makeCtx("voice", getFile), 10_000_000, "tok123"); + await vi.advanceTimersByTimeAsync(5000); + const result = await promise; + + // Should retry transient errors + expect(getFile).toHaveBeenCalledTimes(2); + expect(result).not.toBeNull(); + }); }); diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 76a21acc118..d446176e554 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -6,7 +6,7 @@ import type { RuntimeEnv } from "../../runtime.js"; import type { TelegramInlineButtons } from "../button-types.js"; import type { StickerMetadata, TelegramContext } from "./types.js"; import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js"; -import { danger, logVerbose } from "../../globals.js"; +import { danger, logVerbose, warn } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { retryAsync } from "../../infra/retry.js"; import { mediaKindFromMime } from "../../media/constants.js"; @@ -34,6 +34,7 @@ import { const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/; +const FILE_TOO_BIG_RE = /file is too big/i; export async function deliverReplies(params: { replies: ReplyPayload[]; @@ -414,10 +415,20 @@ export async function resolveMedia( maxDelayMs: 4000, jitter: 0.2, label: "telegram:getFile", + shouldRetry: isRetryableGetFileError, onRetry: ({ attempt, maxAttempts }) => logVerbose(`telegram: getFile retry ${attempt}/${maxAttempts}`), }); } catch (err) { + // Handle "file is too big" separately - Telegram Bot API has a 20MB download limit + if (isFileTooBigError(err)) { + logVerbose( + warn( + "telegram: getFile failed - file exceeds Telegram Bot API 20MB limit; skipping attachment", + ), + ); + return null; + } // All retries exhausted — return null so the message still reaches the agent // with a type-based placeholder (e.g. ) instead of being dropped. logVerbose(`telegram: getFile failed after retries: ${String(err)}`); @@ -442,6 +453,31 @@ function isVoiceMessagesForbidden(err: unknown): boolean { return VOICE_FORBIDDEN_RE.test(formatErrorMessage(err)); } +/** + * Returns true if the error is Telegram's "file is too big" error. + * This happens when trying to download files >20MB via the Bot API. + * Unlike network errors, this is a permanent error and should not be retried. + */ +function isFileTooBigError(err: unknown): boolean { + if (err instanceof GrammyError) { + return FILE_TOO_BIG_RE.test(err.description); + } + return FILE_TOO_BIG_RE.test(formatErrorMessage(err)); +} + +/** + * Returns true if the error is a transient network error that should be retried. + * Returns false for permanent errors like "file is too big" (400 Bad Request). + */ +function isRetryableGetFileError(err: unknown): boolean { + // Don't retry "file is too big" - it's a permanent 400 error + if (isFileTooBigError(err)) { + return false; + } + // Retry all other errors (network issues, timeouts, etc.) + return true; +} + async function sendTelegramVoiceFallbackText(opts: { bot: Bot; chatId: string;