diff --git a/src/config/db.js b/src/config/db.js index 36bc2bc..27a23bb 100644 --- a/src/config/db.js +++ b/src/config/db.js @@ -224,6 +224,7 @@ const migrate = async () => { // safe column alters (existing tables on prod may lack newer columns) await query(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT true`); + await query(`ALTER TABLE scheduled_posts ADD COLUMN IF NOT EXISTS send_attempts INT NOT NULL DEFAULT 0`); console.log('[DB] Migrations applied'); }; diff --git a/src/services/scheduledPostsRunner.js b/src/services/scheduledPostsRunner.js index 1d4e16e..4bd173c 100644 --- a/src/services/scheduledPostsRunner.js +++ b/src/services/scheduledPostsRunner.js @@ -401,6 +401,21 @@ async function publishOne(scheduledPost) { // массового ретрая всё накопившееся улетело бы в канал пачкой. const SKIP_OLDER_THAN_H = 3; +// Ретрай транзиентных ошибок отправки (522/5xx/429/сетевые таймауты). +// Разовый сбой Telegram-прокси (CF Worker) не должен насмерть ронять слот. +const MAX_SEND_ATTEMPTS = 5; +const RETRY_BACKOFF_MIN = [2, 5, 10, 20, 30]; // минуты между попытками +function isTransientSendError(err) { + const status = err.response?.status; + if (status && status >= 500) return true; // 500..599 включая 522 + if (status === 429) return true; // rate limit + const code = String(err.code || ''); + if (['ECONNRESET','ETIMEDOUT','ECONNREFUSED','ECONNABORTED','EAI_AGAIN','EPIPE'].includes(code)) return true; + const m = String(err.message || '').toLowerCase(); + if (m.includes('timeout') || m.includes('socket hang up') || m.includes('network')) return true; + return false; +} + async function runScheduled() { // 1) Помечаем слишком старые pending как skipped (не спамим канал задним числом) const { rows: skipped } = await query( @@ -434,12 +449,24 @@ async function runScheduled() { console.log(`[scheduled-runner] sent id=${sp.id} channel=${sp.channel_id} article=${sp.article_id}`); } catch (err) { const msg = err.response?.data?.description || err.response?.data?.error?.error_msg || err.message; - await query( - `UPDATE scheduled_posts SET status='failed', error=$1 WHERE id=$2`, - [String(msg).slice(0, 1000), sp.id] - ); - results.push({ id: sp.id, ok: false, error: msg }); - console.error(`[scheduled-runner] failed id=${sp.id}: ${msg}`); + const attempts = (sp.send_attempts || 0) + 1; + if (isTransientSendError(err) && attempts < MAX_SEND_ATTEMPTS) { + const delayMin = RETRY_BACKOFF_MIN[Math.min(attempts - 1, RETRY_BACKOFF_MIN.length - 1)]; + const retryAt = new Date(Date.now() + delayMin * 60_000); + await query( + `UPDATE scheduled_posts SET status='pending', scheduled_at=$1, send_attempts=$2, error=$3 WHERE id=$4`, + [retryAt, attempts, `retry ${attempts}/${MAX_SEND_ATTEMPTS} через ${delayMin}м: ${String(msg).slice(0, 300)}`, sp.id] + ); + results.push({ id: sp.id, ok: false, retry: attempts, error: msg }); + console.warn(`[scheduled-runner] transient id=${sp.id}, retry ${attempts}/${MAX_SEND_ATTEMPTS} in ${delayMin}m: ${msg}`); + } else { + await query( + `UPDATE scheduled_posts SET status='failed', send_attempts=$1, error=$2 WHERE id=$3`, + [attempts, String(msg).slice(0, 1000), sp.id] + ); + results.push({ id: sp.id, ok: false, error: msg }); + console.error(`[scheduled-runner] failed id=${sp.id} (attempts=${attempts}): ${msg}`); + } } } return { processed: rows.length, skipped: skipped.length, results };