1.3.8-beta.12: повторы скачивания в живом режиме — фото, файл, видео или голосовое, которое MAX не отдал из-за сбоя (сеть, 5xx, нет связи с MAX), скачивается ещё раз через 2 и через 5 с, не дольше минуты от первой попытки; заглушка — только после третьей, с числом попыток; догрузка истории повторяет как раньше — на следующем проходе

This commit is contained in:
Folist 2026-10-15 20:06:06 +03:00
parent 932db96f14
commit 35c6ea9466
4 changed files with 53 additions and 21 deletions

4
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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<DownloadedAttachment | null> {
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);
}
}
}

View file

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