mirror of
https://github.com/Trollobot/Telemax.git
synced 2026-10-19 10:23:24 +00:00
1.3.8-beta.12: повторы скачивания в живом режиме — фото, файл, видео или голосовое, которое MAX не отдал из-за сбоя (сеть, 5xx, нет связи с MAX), скачивается ещё раз через 2 и через 5 с, не дольше минуты от первой попытки; заглушка — только после третьей, с числом попыток; догрузка истории повторяет как раньше — на следующем проходе
This commit is contained in:
parent
932db96f14
commit
35c6ea9466
4 changed files with 53 additions and 21 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue