From 5a0e804d6e89aac42924405bc60d92b5c3fa02bc Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 13 Mar 2026 11:01:48 +0300 Subject: [PATCH 1/2] feat: include retry history in JSON output for send commands Add --retries flag to `send text` and `send file` commands with exponential backoff. When retries occur, JSON output includes retry_log array and attempts count. Retry progress is written to stderr (JSON in --json mode, human-readable otherwise). Closes #21 Co-Authored-By: Claude Opus 4.6 --- SKILL.md | 2 + cli.js | 71 ++++++++++++------ core/retry.js | 90 ++++++++++++++++++++++ tests/retry.test.js | 179 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 319 insertions(+), 23 deletions(-) create mode 100644 core/retry.js create mode 100644 tests/retry.test.js diff --git a/SKILL.md b/SKILL.md index f34e586..994979d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -67,6 +67,7 @@ tgcli auth - `--caption-above` shows caption above media (`send file` only, requires `--caption`) - `--spoiler` blurs media until tapped (`send file` only) - `--force-document` sends photo/video as uncompressed document (`send file` only) + - `--retries ` retry on failure with exponential backoff (default 0); JSON output includes `retry_log` and `attempts` when retries occurred - Telegram markdown formatting (when `--parse-mode markdown`): - Bold: `**text**` - Italic: `__text__` (double underscores, NOT single `_`) @@ -118,6 +119,7 @@ tgcli send text --to --message "https://example.com check this" - tgcli send text --to --message "Nightly report" --silent --json --timeout 30s tgcli send text --to --message "Confidential" --no-forwards --json --timeout 30s tgcli send text --to --message "Good morning!" --schedule "2025-01-15T09:00:00+03:00" --json --timeout 30s +tgcli send text --to --message "Hello" --retries 3 --json --timeout 30s tgcli send file --to --file /path/to/file --caption "Report" --json --timeout 30s tgcli send file --to --file /path/to/file --caption "Report" --parse-mode html --json --timeout 30s diff --git a/cli.js b/cli.js index 7935b44..be8a065 100755 --- a/cli.js +++ b/cli.js @@ -12,6 +12,7 @@ import { acquireStoreLock, acquireReadLock, readStoreLock } from './store-lock.j import { loadConfig, normalizeConfig, saveConfig, validateConfig } from './core/config.js'; import { createMessageSyncService, createServices, createTelegramClient } from './core/services.js'; import { resolveStoreDir } from './core/store.js'; +import { withSendRetry } from './core/retry.js'; const CLI_PATH = fileURLToPath(import.meta.url); const SERVICE_STATE_FILE = 'service-state.json'; @@ -231,6 +232,7 @@ function buildProgram() { .option('--silent', 'Send without notification sound') .option('--no-forwards', 'Protect message from forwarding') .option('--schedule ', 'Schedule message (ISO 8601 datetime)') + .option('--retries ', 'Max retries on failure', '0') .action(withGlobalOptions((globalFlags, options) => runSendText(globalFlags, options))); send .command('file') @@ -248,6 +250,7 @@ function buildProgram() { .option('--spoiler', 'Blur media until tapped') .option('--schedule ', 'Schedule message (ISO 8601 datetime)') .option('--force-document', 'Send as uncompressed document') + .option('--retries ', 'Max retries on failure', '0') .action(withGlobalOptions((globalFlags, options) => runSendFile(globalFlags, options))); const media = program.command('media').description('Download media'); @@ -549,12 +552,18 @@ function writeJson(payload) { function writeError(error, asJson) { const message = error?.message ?? String(error); if (asJson) { - process.stderr.write(`${JSON.stringify({ ok: false, error: message })}\n`); + const payload = { ok: false, error: message }; + if (error?.retryLog?.length > 0) { + payload.attempts = error.attempts; + payload.retry_log = error.retryLog; + } + process.stderr.write(`${JSON.stringify(payload)}\n`); } else { process.stderr.write(`${message}\n`); } } + function collectOption(value, previous) { return previous.concat([value]); } @@ -2832,16 +2841,24 @@ async function runSendText(globalFlags, options = {}) { const topicId = parsePositiveInt(options.topic, '--topic'); const replyToMessageId = parsePositiveInt(options.replyTo, '--reply-to'); const scheduleDate = parseScheduleDate(options.schedule); - const result = await telegramClient.sendTextMessage(options.to, options.message, { - topicId, - replyToMessageId, - parseMode, - noPreview: options.noPreview, - silent: options.silent || false, - noforwards: options.forwards === false, - scheduleDate, - }); + const retries = Math.max(0, parseInt(options.retries, 10) || 0); + const { result, retryLog, attempts } = await withSendRetry( + () => telegramClient.sendTextMessage(options.to, options.message, { + topicId, + replyToMessageId, + parseMode, + noPreview: options.noPreview, + silent: options.silent || false, + noforwards: options.forwards === false, + scheduleDate, + }), + { retries, json: globalFlags.json } + ); const payload = { channelId: options.to, ...result }; + if (retryLog.length > 0) { + payload.attempts = attempts; + payload.retry_log = retryLog; + } if (globalFlags.json) { writeJson(payload); @@ -2879,20 +2896,28 @@ async function runSendFile(globalFlags, options = {}) { const topicId = parsePositiveInt(options.topic, '--topic'); const replyToMessageId = parsePositiveInt(options.replyTo, '--reply-to'); const scheduleDate = parseScheduleDate(options.schedule); - const result = await telegramClient.sendFileMessage(options.to, options.file, { - caption: options.caption, - filename: options.filename, - topicId, - replyToMessageId, - parseMode, - silent: options.silent || false, - noforwards: options.forwards === false, - captionAbove: options.captionAbove || false, - spoiler: options.spoiler || false, - scheduleDate, - forceDocument: options.forceDocument || false, - }); + const retries = Math.max(0, parseInt(options.retries, 10) || 0); + const { result, retryLog, attempts } = await withSendRetry( + () => telegramClient.sendFileMessage(options.to, options.file, { + caption: options.caption, + filename: options.filename, + topicId, + replyToMessageId, + parseMode, + silent: options.silent || false, + noforwards: options.forwards === false, + captionAbove: options.captionAbove || false, + spoiler: options.spoiler || false, + scheduleDate, + forceDocument: options.forceDocument || false, + }), + { retries, json: globalFlags.json } + ); const payload = { channelId: options.to, ...result }; + if (retryLog.length > 0) { + payload.attempts = attempts; + payload.retry_log = retryLog; + } if (globalFlags.json) { writeJson(payload); diff --git a/core/retry.js b/core/retry.js new file mode 100644 index 0000000..40d929a --- /dev/null +++ b/core/retry.js @@ -0,0 +1,90 @@ +import { setTimeout as delay } from 'timers/promises'; + +function formatErrorMessage(error) { + if (error instanceof Error && error.message) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return String(error); +} + +function parseRequiredWaitSeconds(error) { + const text = formatErrorMessage(error); + const waitMatch = /wait of (\d+) seconds is required/i.exec(text); + if (waitMatch) { + return Number(waitMatch[1]); + } + const floodWaitMatch = /FLOOD_WAIT_(\d+)/i.exec(text); + if (floodWaitMatch) { + return Number(floodWaitMatch[1]); + } + return null; +} + +function classifyError(error) { + const message = formatErrorMessage(error); + const code = error?.code ?? null; + if (parseRequiredWaitSeconds(error) !== null || /FLOOD_WAIT/i.test(message)) { + return { type: 'rate_limit', message, code }; + } + if (/ECONNRESET|ETIMEDOUT|ENETUNREACH/i.test(message) || + /ECONNRESET|ETIMEDOUT|ENETUNREACH/.test(code ?? '')) { + return { type: 'network', message, code }; + } + return { type: 'api', message, code }; +} + +function computeRetryWaitSeconds(error, attempt) { + const rateLimitWait = parseRequiredWaitSeconds(error); + if (rateLimitWait !== null) { + return rateLimitWait; + } + // Exponential backoff: 1s, 2s, 4s, ... + return Math.pow(2, attempt - 1); +} + +async function withSendRetry(fn, options = {}) { + const maxRetries = options.retries ?? 0; + const json = options.json ?? false; + const retryLog = []; + const maxAttempts = maxRetries + 1; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const result = await fn(); + return { result, retryLog, attempts: attempt }; + } catch (error) { + if (attempt >= maxAttempts) { + error.retryLog = retryLog; + error.attempts = attempt; + throw error; + } + + const classified = classifyError(error); + const waitSeconds = computeRetryWaitSeconds(error, attempt); + + retryLog.push({ + attempt, + error: classified, + }); + + if (json) { + process.stderr.write(`${JSON.stringify({ + event: 'retry', + attempt, + maxAttempts, + error: classified, + waitSeconds, + })}\n`); + } else { + process.stderr.write(`Retry ${attempt}/${maxRetries}: ${classified.type.toUpperCase()} — ${classified.message}. Waiting ${waitSeconds}s...\n`); + } + + await delay(waitSeconds * 1000); + } + } +} + +export { classifyError, computeRetryWaitSeconds, withSendRetry }; diff --git a/tests/retry.test.js b/tests/retry.test.js new file mode 100644 index 0000000..e2560c8 --- /dev/null +++ b/tests/retry.test.js @@ -0,0 +1,179 @@ +vi.mock('timers/promises', () => ({ + setTimeout: vi.fn().mockResolvedValue(undefined), +})); + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { classifyError, computeRetryWaitSeconds, withSendRetry } from '../core/retry.js'; + +describe('classifyError', () => { + it('classifies FLOOD_WAIT_30 as rate_limit', () => { + const error = new Error('FLOOD_WAIT_30'); + const result = classifyError(error); + expect(result.type).toBe('rate_limit'); + expect(result.message).toBe('FLOOD_WAIT_30'); + expect(result.code).toBeNull(); + }); + + it('classifies "wait of 60 seconds is required" as rate_limit', () => { + const error = new Error('A wait of 60 seconds is required'); + const result = classifyError(error); + expect(result.type).toBe('rate_limit'); + }); + + it('classifies error with code ECONNRESET as network', () => { + const error = new Error('connection reset'); + error.code = 'ECONNRESET'; + const result = classifyError(error); + expect(result.type).toBe('network'); + expect(result.code).toBe('ECONNRESET'); + }); + + it('classifies error with ETIMEDOUT in message as network', () => { + const error = new Error('connect ETIMEDOUT 1.2.3.4:443'); + const result = classifyError(error); + expect(result.type).toBe('network'); + }); + + it('classifies generic error as api', () => { + const error = new Error('Something went wrong'); + const result = classifyError(error); + expect(result.type).toBe('api'); + expect(result.message).toBe('Something went wrong'); + expect(result.code).toBeNull(); + }); + + it('handles string errors', () => { + const result = classifyError('FLOOD_WAIT_10'); + expect(result.type).toBe('rate_limit'); + }); + + it('handles non-Error objects', () => { + const result = classifyError({ code: 'ECONNRESET' }); + expect(result.type).toBe('network'); + expect(result.code).toBe('ECONNRESET'); + }); +}); + +describe('computeRetryWaitSeconds', () => { + it('returns parsed seconds for FLOOD_WAIT_30', () => { + const error = new Error('FLOOD_WAIT_30'); + expect(computeRetryWaitSeconds(error, 1)).toBe(30); + }); + + it('returns parsed seconds for "wait of 60 seconds" error', () => { + const error = new Error('A wait of 60 seconds is required'); + expect(computeRetryWaitSeconds(error, 1)).toBe(60); + }); + + it('returns 1 for non-rate-limit error, attempt 1', () => { + const error = new Error('generic error'); + expect(computeRetryWaitSeconds(error, 1)).toBe(1); + }); + + it('returns 2 for non-rate-limit error, attempt 2', () => { + const error = new Error('generic error'); + expect(computeRetryWaitSeconds(error, 2)).toBe(2); + }); + + it('returns 4 for non-rate-limit error, attempt 3', () => { + const error = new Error('generic error'); + expect(computeRetryWaitSeconds(error, 3)).toBe(4); + }); +}); + +describe('withSendRetry', () => { + let stderrSpy; + + beforeEach(() => { + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + }); + + it('returns result on first attempt success', async () => { + const fn = vi.fn().mockResolvedValue('ok'); + const result = await withSendRetry(fn, { retries: 3 }); + expect(result).toEqual({ result: 'ok', retryLog: [], attempts: 1 }); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('returns result after 1 retry', async () => { + const fn = vi.fn() + .mockRejectedValueOnce(new Error('temporary failure')) + .mockResolvedValueOnce('ok'); + const result = await withSendRetry(fn, { retries: 2 }); + expect(result.result).toBe('ok'); + expect(result.attempts).toBe(2); + expect(result.retryLog).toHaveLength(1); + expect(result.retryLog[0].attempt).toBe(1); + expect(result.retryLog[0].error.type).toBe('api'); + expect(result.retryLog[0].error.message).toBe('temporary failure'); + }); + + it('throws after max retries exhausted', async () => { + const fn = vi.fn().mockRejectedValue(new Error('persistent failure')); + try { + await withSendRetry(fn, { retries: 2 }); + expect.unreachable('should have thrown'); + } catch (error) { + expect(error.message).toBe('persistent failure'); + expect(error.retryLog).toHaveLength(2); + expect(error.attempts).toBe(3); + } + }); + + it('throws immediately when retries is 0', async () => { + const fn = vi.fn().mockRejectedValue(new Error('fail')); + try { + await withSendRetry(fn, { retries: 0 }); + expect.unreachable('should have thrown'); + } catch (error) { + expect(error.message).toBe('fail'); + expect(error.retryLog).toEqual([]); + expect(error.attempts).toBe(1); + expect(fn).toHaveBeenCalledTimes(1); + } + }); + + it('writes JSON retry event to stderr in json mode', async () => { + const fn = vi.fn() + .mockRejectedValueOnce(new Error('FLOOD_WAIT_5')) + .mockResolvedValueOnce('ok'); + await withSendRetry(fn, { retries: 1, json: true }); + expect(stderrSpy).toHaveBeenCalledTimes(1); + const written = stderrSpy.mock.calls[0][0]; + const parsed = JSON.parse(written.trim()); + expect(parsed.event).toBe('retry'); + expect(parsed.attempt).toBe(1); + expect(parsed.maxAttempts).toBe(2); + expect(parsed.error.type).toBe('rate_limit'); + expect(parsed.waitSeconds).toBe(5); + }); + + it('writes human-readable retry message to stderr in non-json mode', async () => { + const fn = vi.fn() + .mockRejectedValueOnce(new Error('connection lost')) + .mockResolvedValueOnce('ok'); + await withSendRetry(fn, { retries: 1, json: false }); + expect(stderrSpy).toHaveBeenCalledTimes(1); + const written = stderrSpy.mock.calls[0][0]; + expect(written).toContain('Retry 1/1'); + expect(written).toContain('API'); + expect(written).toContain('connection lost'); + expect(written).toContain('Waiting 1s'); + }); + + it('accumulates multiple retries in retryLog', async () => { + const fn = vi.fn() + .mockRejectedValueOnce(new Error('fail 1')) + .mockRejectedValueOnce(new Error('fail 2')) + .mockResolvedValueOnce('ok'); + const result = await withSendRetry(fn, { retries: 3 }); + expect(result.attempts).toBe(3); + expect(result.retryLog).toHaveLength(2); + expect(result.retryLog[0].attempt).toBe(1); + expect(result.retryLog[1].attempt).toBe(2); + }); +}); From 1fc630ff2ed041ec802737b44c0da36c2017dca8 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 13 Mar 2026 11:15:45 +0300 Subject: [PATCH 2/2] fix: only retry retryable errors, add maxWaitSeconds guard - withSendRetry now only retries rate_limit and network errors; api errors (CHAT_WRITE_FORBIDDEN, PEER_ID_INVALID) throw immediately - Add maxWaitSeconds option (default 300s) to cap FLOOD_WAIT delays - Include waitSeconds in retryLog entries for full retry visibility - Remove duplicate formatErrorMessage/parseRequiredWaitSeconds from cli.js, import from core/retry.js instead - Fix refreshDialogsWithRetry to log to stderr instead of stdout Closes review findings from PR #23. See #25 for remaining refactoring. Co-Authored-By: Claude Opus 4.6 --- cli.js | 27 ++--------------------- core/retry.js | 15 ++++++++++--- tests/retry.test.js | 52 ++++++++++++++++++++++++++++++++++----------- 3 files changed, 54 insertions(+), 40 deletions(-) diff --git a/cli.js b/cli.js index be8a065..845049e 100755 --- a/cli.js +++ b/cli.js @@ -12,7 +12,7 @@ import { acquireStoreLock, acquireReadLock, readStoreLock } from './store-lock.j import { loadConfig, normalizeConfig, saveConfig, validateConfig } from './core/config.js'; import { createMessageSyncService, createServices, createTelegramClient } from './core/services.js'; import { resolveStoreDir } from './core/store.js'; -import { withSendRetry } from './core/retry.js'; +import { formatErrorMessage, parseRequiredWaitSeconds, withSendRetry } from './core/retry.js'; const CLI_PATH = fileURLToPath(import.meta.url); const SERVICE_STATE_FILE = 'service-state.json'; @@ -936,29 +936,6 @@ function runWithTimeout(task, timeoutMs, onTimeout) { }); } -function formatErrorMessage(error) { - if (error instanceof Error && error.message) { - return error.message; - } - if (typeof error === 'string') { - return error; - } - return String(error); -} - -function parseRequiredWaitSeconds(error) { - const text = formatErrorMessage(error); - const waitMatch = /wait of (\d+) seconds is required/i.exec(text); - if (waitMatch) { - return Number(waitMatch[1]); - } - const floodWaitMatch = /FLOOD_WAIT_(\d+)/i.exec(text); - if (floodWaitMatch) { - return Number(floodWaitMatch[1]); - } - return null; -} - async function refreshDialogsWithRetry(messageSyncService, options = {}) { const maxWaitSeconds = options.maxWaitSeconds ?? 30; try { @@ -968,7 +945,7 @@ async function refreshDialogsWithRetry(messageSyncService, options = {}) { if (!waitSeconds || waitSeconds > maxWaitSeconds) { throw error; } - console.log(`Rate limited while seeding dialogs. Waiting ${waitSeconds}s and retrying once...`); + process.stderr.write(`Rate limited while seeding dialogs. Waiting ${waitSeconds}s and retrying once...\n`); await delay(waitSeconds * 1000); return await messageSyncService.refreshChannelsFromDialogs(); } diff --git a/core/retry.js b/core/retry.js index 40d929a..4d85a96 100644 --- a/core/retry.js +++ b/core/retry.js @@ -48,6 +48,7 @@ function computeRetryWaitSeconds(error, attempt) { async function withSendRetry(fn, options = {}) { const maxRetries = options.retries ?? 0; const json = options.json ?? false; + const maxWaitSeconds = options.maxWaitSeconds ?? 300; const retryLog = []; const maxAttempts = maxRetries + 1; @@ -56,18 +57,26 @@ async function withSendRetry(fn, options = {}) { const result = await fn(); return { result, retryLog, attempts: attempt }; } catch (error) { - if (attempt >= maxAttempts) { + const classified = classifyError(error); + + if (attempt >= maxAttempts || classified.type === 'api') { error.retryLog = retryLog; error.attempts = attempt; throw error; } - const classified = classifyError(error); const waitSeconds = computeRetryWaitSeconds(error, attempt); + if (waitSeconds > maxWaitSeconds) { + error.retryLog = retryLog; + error.attempts = attempt; + throw error; + } + retryLog.push({ attempt, error: classified, + waitSeconds, }); if (json) { @@ -87,4 +96,4 @@ async function withSendRetry(fn, options = {}) { } } -export { classifyError, computeRetryWaitSeconds, withSendRetry }; +export { formatErrorMessage, parseRequiredWaitSeconds, classifyError, computeRetryWaitSeconds, withSendRetry }; diff --git a/tests/retry.test.js b/tests/retry.test.js index e2560c8..97b220c 100644 --- a/tests/retry.test.js +++ b/tests/retry.test.js @@ -99,26 +99,40 @@ describe('withSendRetry', () => { expect(fn).toHaveBeenCalledTimes(1); }); - it('returns result after 1 retry', async () => { + it('returns result after 1 retry on network error', async () => { + const networkError = new Error('connect ETIMEDOUT 1.2.3.4:443'); const fn = vi.fn() - .mockRejectedValueOnce(new Error('temporary failure')) + .mockRejectedValueOnce(networkError) .mockResolvedValueOnce('ok'); const result = await withSendRetry(fn, { retries: 2 }); expect(result.result).toBe('ok'); expect(result.attempts).toBe(2); expect(result.retryLog).toHaveLength(1); expect(result.retryLog[0].attempt).toBe(1); - expect(result.retryLog[0].error.type).toBe('api'); - expect(result.retryLog[0].error.message).toBe('temporary failure'); + expect(result.retryLog[0].error.type).toBe('network'); + expect(result.retryLog[0].waitSeconds).toBe(1); }); - it('throws after max retries exhausted', async () => { - const fn = vi.fn().mockRejectedValue(new Error('persistent failure')); + it('throws immediately on non-retryable api error', async () => { + const fn = vi.fn().mockRejectedValue(new Error('CHAT_WRITE_FORBIDDEN')); + try { + await withSendRetry(fn, { retries: 3 }); + expect.unreachable('should have thrown'); + } catch (error) { + expect(error.message).toBe('CHAT_WRITE_FORBIDDEN'); + expect(error.retryLog).toEqual([]); + expect(error.attempts).toBe(1); + expect(fn).toHaveBeenCalledTimes(1); + } + }); + + it('throws after max retries exhausted on network errors', async () => { + const fn = vi.fn().mockRejectedValue(new Error('connect ECONNRESET')); try { await withSendRetry(fn, { retries: 2 }); expect.unreachable('should have thrown'); } catch (error) { - expect(error.message).toBe('persistent failure'); + expect(error.message).toBe('connect ECONNRESET'); expect(error.retryLog).toHaveLength(2); expect(error.attempts).toBe(3); } @@ -137,6 +151,18 @@ describe('withSendRetry', () => { } }); + it('throws when FLOOD_WAIT exceeds maxWaitSeconds', async () => { + const fn = vi.fn().mockRejectedValue(new Error('FLOOD_WAIT_3600')); + try { + await withSendRetry(fn, { retries: 3, maxWaitSeconds: 60 }); + expect.unreachable('should have thrown'); + } catch (error) { + expect(error.message).toBe('FLOOD_WAIT_3600'); + expect(error.attempts).toBe(1); + expect(fn).toHaveBeenCalledTimes(1); + } + }); + it('writes JSON retry event to stderr in json mode', async () => { const fn = vi.fn() .mockRejectedValueOnce(new Error('FLOOD_WAIT_5')) @@ -154,26 +180,28 @@ describe('withSendRetry', () => { it('writes human-readable retry message to stderr in non-json mode', async () => { const fn = vi.fn() - .mockRejectedValueOnce(new Error('connection lost')) + .mockRejectedValueOnce(new Error('connect ETIMEDOUT 1.2.3.4:443')) .mockResolvedValueOnce('ok'); await withSendRetry(fn, { retries: 1, json: false }); expect(stderrSpy).toHaveBeenCalledTimes(1); const written = stderrSpy.mock.calls[0][0]; expect(written).toContain('Retry 1/1'); - expect(written).toContain('API'); - expect(written).toContain('connection lost'); + expect(written).toContain('NETWORK'); + expect(written).toContain('ETIMEDOUT'); expect(written).toContain('Waiting 1s'); }); it('accumulates multiple retries in retryLog', async () => { const fn = vi.fn() - .mockRejectedValueOnce(new Error('fail 1')) - .mockRejectedValueOnce(new Error('fail 2')) + .mockRejectedValueOnce(new Error('connect ECONNRESET')) + .mockRejectedValueOnce(new Error('connect ETIMEDOUT')) .mockResolvedValueOnce('ok'); const result = await withSendRetry(fn, { retries: 3 }); expect(result.attempts).toBe(3); expect(result.retryLog).toHaveLength(2); expect(result.retryLog[0].attempt).toBe(1); + expect(result.retryLog[0].waitSeconds).toBe(1); expect(result.retryLog[1].attempt).toBe(2); + expect(result.retryLog[1].waitSeconds).toBe(2); }); });