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 () => {