From 35c6ea946618e895e72855cd65796b55f2831c44 Mon Sep 17 00:00:00 2001 From: Folist Date: Thu, 15 Oct 2026 20:06:06 +0300 Subject: [PATCH] =?UTF-8?q?1.3.8-beta.12:=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D1=8B=20=D1=81=D0=BA=D0=B0=D1=87=D0=B8=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B2=20=D0=B6=D0=B8=D0=B2=D0=BE=D0=BC=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B6=D0=B8=D0=BC=D0=B5=20=E2=80=94=20=D1=84=D0=BE=D1=82?= =?UTF-8?q?=D0=BE,=20=D1=84=D0=B0=D0=B9=D0=BB,=20=D0=B2=D0=B8=D0=B4=D0=B5?= =?UTF-8?q?=D0=BE=20=D0=B8=D0=BB=D0=B8=20=D0=B3=D0=BE=D0=BB=D0=BE=D1=81?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=B5,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B5=20MAX=20=D0=BD=D0=B5=20=D0=BE=D1=82=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=20=D0=B8=D0=B7-=D0=B7=D0=B0=20=D1=81=D0=B1=D0=BE=D1=8F?= =?UTF-8?q?=20(=D1=81=D0=B5=D1=82=D1=8C,=205xx,=20=D0=BD=D0=B5=D1=82=20?= =?UTF-8?q?=D1=81=D0=B2=D1=8F=D0=B7=D0=B8=20=D1=81=20MAX),=20=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D1=87=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=B5?= =?UTF-8?q?=D1=89=D1=91=20=D1=80=D0=B0=D0=B7=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=202=20=D0=B8=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=205=20?= =?UTF-8?q?=D1=81,=20=D0=BD=D0=B5=20=D0=B4=D0=BE=D0=BB=D1=8C=D1=88=D0=B5?= =?UTF-8?q?=20=D0=BC=D0=B8=D0=BD=D1=83=D1=82=D1=8B=20=D0=BE=D1=82=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=BE=D0=B9=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82?= =?UTF-8?q?=D0=BA=D0=B8;=20=D0=B7=D0=B0=D0=B3=D0=BB=D1=83=D1=88=D0=BA?= =?UTF-8?q?=D0=B0=20=E2=80=94=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=82=D1=80=D0=B5=D1=82=D1=8C?= =?UTF-8?q?=D0=B5=D0=B9,=20=D1=81=20=D1=87=D0=B8=D1=81=D0=BB=D0=BE=D0=BC?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BE=D0=BA;=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=B8=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D1=8F?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BA=D0=B0=D0=BA=20=D1=80=D0=B0=D0=BD=D1=8C?= =?UTF-8?q?=D1=88=D0=B5=20=E2=80=94=20=D0=BD=D0=B0=20=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=D0=B4=D1=83=D1=8E=D1=89=D0=B5=D0=BC=20=D0=BF=D1=80=D0=BE=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 ++-- package.json | 2 +- src/bridge/attachments.ts | 41 ++++++++++++++++++++++++++++----------- tests/transient.test.ts | 27 +++++++++++++++++++------- 4 files changed, 53 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index dd75936..20d7051 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "telemax", - "version": "1.3.8-beta.11", + "version": "1.3.8-beta.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "telemax", - "version": "1.3.8-beta.11", + "version": "1.3.8-beta.12", "license": "AGPL-3.0-only", "dependencies": { "dotenv": "^17.2.3", diff --git a/package.json b/package.json index e7bb2ea..c005166 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "telemax", "private": true, "license": "AGPL-3.0-only", - "version": "1.3.8-beta.11", + "version": "1.3.8-beta.12", "type": "module", "scripts": { "dev": "tsx watch src/server/app.ts", diff --git a/src/bridge/attachments.ts b/src/bridge/attachments.ts index 62d692c..47a02da 100644 --- a/src/bridge/attachments.ts +++ b/src/bridge/attachments.ts @@ -92,12 +92,20 @@ export interface DownloadContext { // Retried with these when the primary (source) ids are denied — see sync.ts forwardDownloadIds. fallbackChatId?: unknown; fallbackMessageId?: unknown; - // Throw TransientDownloadError instead of returning null when the download failed for a reason - // that may pass (MAX socket down, CDN 5xx/network). Set by the backfill, which must not advance - // its cursor past a file it could not fetch; the live path keeps the text placeholder. + // Throw TransientDownloadError instead of a DownloadMissError when the download failed for a + // reason that may pass (MAX socket down, CDN 5xx/network). Set by the backfill, which must not + // advance its cursor past a file it could not fetch (it tries again on its next run); the live + // path tries again itself (LIVE_RETRY_DELAYS_MS), then posts the placeholder. throwOnTransient?: boolean; + /** The live path's pauses between tries — injectable for tests. */ + retryDelaysMs?: readonly number[]; } +/** Live: pauses before the 2nd and 3rd try of a download that failed transiently — a CDN hiccup lasts seconds (a user report on 1.3.7: «то фото, то заглушка», one try only). */ +const LIVE_RETRY_DELAYS_MS = [2000, 5000]; +/** …and no new try once this long has gone by since the first: the chat's queue waits for it. */ +const LIVE_RETRY_BUDGET_MS = 60_000; + // Generous (a large video on a slow CDN) but finite: a hung download must not stall the relay forever. const DOWNLOAD_TIMEOUT_MS = 120_000; // Bot API upload limits (multipart): 50 MB for any file, 10 MB for a photo — bigger is refused with 413. @@ -247,16 +255,27 @@ export async function getMaxFileUrl(att: MaxAttachment, ctx: DownloadContext): P * Downloads a MAX attachment for re-upload to Telegram. Null means "nothing to fetch" (a kind the * bridge does not download) — the caller posts describeAttachment()'s placeholder. DownloadMissError * means it could not be had: the caller posts placeholderText with its `why`. A transient failure - * (see TransientDownloadError) becomes a DownloadMissError too, unless ctx.throwOnTransient is set — - * then it is thrown so the backfill can stop without moving its cursor past the file. + * (see TransientDownloadError) is tried again (LIVE_RETRY_DELAYS_MS) and then becomes a + * DownloadMissError too — unless ctx.throwOnTransient is set: then it is thrown at once so the + * backfill can stop without moving its cursor past the file. */ export async function downloadMaxAttachment(att: MaxAttachment, ctx: DownloadContext): Promise { - try { - return await downloadAttachmentOrThrow(att, ctx); - } catch (err) { - if (!(err instanceof TransientDownloadError) || ctx.throwOnTransient) throw err; - logger.error(`Transient failure downloading a MAX ${att._type ?? 'attachment'} — sending the placeholder instead`, err); - throw new DownloadMissError(err.why, err); + const delays = ctx.retryDelaysMs ?? LIVE_RETRY_DELAYS_MS; + const startedAt = Date.now(); + for (let tries = 1; ; tries++) { + try { + return await downloadAttachmentOrThrow(att, ctx); + } catch (err) { + if (!(err instanceof TransientDownloadError) || ctx.throwOnTransient) throw err; + const pause = delays[tries - 1]; + if (pause != null && Date.now() - startedAt + pause < LIVE_RETRY_BUDGET_MS) { + logger.info(`Transient failure downloading a MAX ${att._type ?? 'attachment'} (try ${tries}: ${err.message}) — trying again in ${pause / 1000} s`); + await new Promise((resolve) => setTimeout(resolve, pause)); + continue; + } + logger.error(`Transient failure downloading a MAX ${att._type ?? 'attachment'} after ${tries} tries — sending the placeholder instead`, err); + throw new DownloadMissError(tries > 1 ? `${err.why}, попыток: ${tries}` : err.why, err); + } } } diff --git a/tests/transient.test.ts b/tests/transient.test.ts index d17f3a3..3340e42 100644 --- a/tests/transient.test.ts +++ b/tests/transient.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { isStaleCallbackQuery, isTransientHttpStatus, @@ -99,16 +99,29 @@ describe('isTransientHttpStatus', () => { describe('downloadMaxAttachment transient reporting', () => { const file = { _type: 'FILE', fileId: 7n, name: 'a.pdf' } as const; - function ctx(err: Error, throwOnTransient?: boolean): DownloadContext { - const max = { getFileDownloadUrl: async () => Promise.reject(err) } as unknown as MaxClient; - return { max, chatId: 1, messageId: 2, throwOnTransient }; + function ctx(err: Error, throwOnTransient?: boolean): DownloadContext & { calls: () => number } { + const getFileDownloadUrl = vi.fn(async () => Promise.reject(err)); + const max = { getFileDownloadUrl } as unknown as MaxClient; + return { max, chatId: 1, messageId: 2, throwOnTransient, retryDelaysMs: [0, 0], calls: () => getFileDownloadUrl.mock.calls.length }; } it('throws TransientDownloadError for a dead MAX socket only when the caller opted in (backfill)', async () => { const down = new Error('MaxClient.send called while not connected'); - await expect(downloadMaxAttachment(file, ctx(down, true))).rejects.toBeInstanceOf(TransientDownloadError); - // Live path: the placeholder, saying why. - await expect(downloadMaxAttachment(file, ctx(down))).rejects.toMatchObject({ name: 'DownloadMissError', why: 'не скачалось: нет связи с MAX' }); + const backfill = ctx(down, true); + await expect(downloadMaxAttachment(file, backfill)).rejects.toBeInstanceOf(TransientDownloadError); + expect(backfill.calls()).toBe(1); // the backfill retries on its next run, not here + // Live path: three tries, then the placeholder, saying why. + const live = ctx(down); + await expect(downloadMaxAttachment(file, live)).rejects.toMatchObject({ name: 'DownloadMissError', why: 'не скачалось: нет связи с MAX, попыток: 3' }); + expect(live.calls()).toBe(3); + }); + + it('a live retry stops at a permanent refusal', async () => { + const getFileDownloadUrl = vi.fn().mockRejectedValueOnce(new Error('MaxClient.send called while not connected')).mockRejectedValueOnce(new Error('FILE_DOWNLOAD did not return a url: file not found')); + const max = { getFileDownloadUrl } as unknown as MaxClient; + // The second try gets a permanent refusal: no third one, the miss says so. + await expect(downloadMaxAttachment(file, { max, chatId: 1, messageId: 2, retryDelaysMs: [0, 0] })).rejects.toMatchObject({ why: NO_LINK_WHY }); + expect(getFileDownloadUrl).toHaveBeenCalledTimes(2); }); it('a permanent MAX refusal is a miss either way — MAX gave no link', async () => {