From c01f398938131ab419f385140fc5d7594b316bbb Mon Sep 17 00:00:00 2001 From: paypes <43441600+abbesBenayache@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:47:50 +0100 Subject: [PATCH] fix(dapp): implement rate limiting to prevent 429 errors - Add 1 second delay before each Telegram API call to respect rate limits - Increase maxRetries to 10 for better error recovery - Improve error handling: HTTP errors (non-429) fail immediately without retry - Only retry on 429 rate limit errors and network errors - Add unit and e2e tests for rate limiting behavior --- dapp/src/telegramService.js | 82 ++++- .../_test_outputs_/iexec_out/result.json | 54 +++- dapp/tests/e2e/app.test.js | 66 +++- dapp/tests/unit/telegramService.test.js | 292 ++++++++++++++++-- 4 files changed, 453 insertions(+), 41 deletions(-) diff --git a/dapp/src/telegramService.js b/dapp/src/telegramService.js index 0acdaaf..8fc90f7 100644 --- a/dapp/src/telegramService.js +++ b/dapp/src/telegramService.js @@ -3,11 +3,18 @@ async function sendTelegram({ message, botToken, senderName = 'Web3Telegram Dapp', + maxRetries = 10, + initialDelay = 1000, }) { const messageToSend = `Message from: ${senderName}\n${message}`; - const response = await fetch( - `https://api.telegram.org/bot${botToken}/sendMessage`, - { + + const sendMessage = async () => { + // wait 1 second before each call to avoid rate limit + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + + return fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -17,14 +24,69 @@ async function sendTelegram({ text: messageToSend, parse_mode: 'HTML', }), + }); + }; + + // retry logic with exponential backoff for handling rate limits (429) and network errors + // eslint-disable-next-line no-plusplus + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + try { + // eslint-disable-next-line no-await-in-loop + const response = await sendMessage(); + + if (response.ok) { + return; + } + + if (response.status === 429) { + const retryAfter = response.headers.get('Retry-After'); + const delay = retryAfter + ? parseInt(retryAfter, 10) * 1000 + : initialDelay * 2 ** attempt; + + if (attempt < maxRetries) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, delay); + }); + // eslint-disable-next-line no-continue + continue; + } + + throw new Error( + `Failed to send Telegram message: Rate limit exceeded after ${ + maxRetries + 1 + } attempts` + ); + } + + // other HTTP errors - throw directly, no retry + throw new Error( + `Failed to send Telegram message, bot API answered with status: ${response.status}` + ); + } catch (error) { + // if it's an HTTP error (404, 400, etc.) or rate limit error, re-throw immediately + if ( + error.message.includes('Rate limit') || + error.message.includes('Failed to send') + ) { + throw error; + } + + // network errors - retry with exponential backoff + if (attempt < maxRetries) { + const delay = initialDelay * 2 ** attempt; + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, delay); + }); + // eslint-disable-next-line no-continue + continue; + } + + // max retries reached for network errors + throw new Error('Failed to reach Telegram bot API'); } - ).catch(() => { - throw new Error('Failed to reach Telegram bot API'); - }); - if (!response.ok) { - throw new Error( - `Failed to send Telegram message, bot API answered with status: ${response.status}` - ); } } diff --git a/dapp/tests/_test_outputs_/iexec_out/result.json b/dapp/tests/_test_outputs_/iexec_out/result.json index 75546f1..31cb693 100644 --- a/dapp/tests/_test_outputs_/iexec_out/result.json +++ b/dapp/tests/_test_outputs_/iexec_out/result.json @@ -1,9 +1,8 @@ { - "success": false, - "error": "Partial failure", - "totalCount": 2, - "successCount": 1, - "errorCount": 1, + "success": true, + "totalCount": 10, + "successCount": 10, + "errorCount": 0, "results": [ { "index": 1, @@ -12,9 +11,48 @@ }, { "index": 2, - "protectedData": "invalid-data.zip", - "success": false, - "error": "Failed to parse ProtectedData 2: Failed to load protected data" + "protectedData": "data-chatId.zip", + "success": true + }, + { + "index": 3, + "protectedData": "data-chatId.zip", + "success": true + }, + { + "index": 4, + "protectedData": "data-chatId.zip", + "success": true + }, + { + "index": 5, + "protectedData": "data-chatId.zip", + "success": true + }, + { + "index": 6, + "protectedData": "data-chatId.zip", + "success": true + }, + { + "index": 7, + "protectedData": "data-chatId.zip", + "success": true + }, + { + "index": 8, + "protectedData": "data-chatId.zip", + "success": true + }, + { + "index": 9, + "protectedData": "data-chatId.zip", + "success": true + }, + { + "index": 10, + "protectedData": "data-chatId.zip", + "success": true } ] } \ No newline at end of file diff --git a/dapp/tests/e2e/app.test.js b/dapp/tests/e2e/app.test.js index 3fca2c9..4f032e5 100644 --- a/dapp/tests/e2e/app.test.js +++ b/dapp/tests/e2e/app.test.js @@ -206,7 +206,7 @@ describe('sendTelegram', () => { expect(computed).toStrictEqual({ 'deterministic-output-path': `${IEXEC_OUT}/result.json`, }); - }); + }, 10000); it('should send a telegram message successfully', async () => { await expect(start()).resolves.toBeUndefined(); @@ -220,6 +220,36 @@ describe('sendTelegram', () => { }); }); + describe('Rate Limiting', () => { + it('should handle multiple messages with 1 second delay between calls', async () => { + // Setup bulk processing with 5 messages to test rate limiting + process.env.IEXEC_BULK_SLICE_SIZE = '5'; + // eslint-disable-next-line no-plusplus + for (let i = 1; i <= 5; i += 1) { + process.env[`IEXEC_DATASET_${i}_FILENAME`] = 'data-chatId.zip'; + } + + await expect(start()).resolves.toBeUndefined(); + + const { IEXEC_OUT } = process.env; + const { result, computed } = await readOutputs(IEXEC_OUT); + + expect(result.success).toBe(true); + expect(result.totalCount).toBe(5); + expect(result.successCount).toBe(5); + expect(result.errorCount).toBe(0); + + // Verify all messages were sent successfully + result.results.forEach((r) => { + expect(r.success).toBe(true); + }); + + expect(computed).toStrictEqual({ + 'deterministic-output-path': `${IEXEC_OUT}/result.json`, + }); + }, 30000); // 30 seconds timeout for 5 messages with delays + }); + describe('Bulk Processing', () => { beforeEach(() => { // Setup bulk processing environment @@ -294,4 +324,38 @@ describe('sendTelegram', () => { }); }); }); + + describe('Retry mechanism with rate limiting', () => { + it('should successfully send 10 Telegram messages with 1 second delay and handle retries', async () => { + // Setup bulk processing with 10 protected data to test rate limiting and retries + process.env.IEXEC_BULK_SLICE_SIZE = '10'; + + // eslint-disable-next-line no-plusplus + for (let i = 1; i <= 10; i += 1) { + process.env[`IEXEC_DATASET_${i}_FILENAME`] = 'data-chatId.zip'; + } + + await expect(start()).resolves.toBeUndefined(); + + const { IEXEC_OUT } = process.env; + const { result, computed } = await readOutputs(IEXEC_OUT); + + expect(result.success).toBe(true); + expect(result.totalCount).toBe(10); + expect(result.successCount).toBe(10); + expect(result.errorCount).toBe(0); + expect(result.results).toHaveLength(10); + + // Verify all messages were sent successfully + result.results.forEach((r, index) => { + expect(r.success).toBe(true); + expect(r.index).toBe(index + 1); + expect(r.protectedData).toBe('data-chatId.zip'); + }); + + expect(computed).toStrictEqual({ + 'deterministic-output-path': `${IEXEC_OUT}/result.json`, + }); + }, 60000); // 60 seconds timeout for 10 messages with delays and potential retries + }); }); diff --git a/dapp/tests/unit/telegramService.test.js b/dapp/tests/unit/telegramService.test.js index 0a55c1b..bca8aa2 100644 --- a/dapp/tests/unit/telegramService.test.js +++ b/dapp/tests/unit/telegramService.test.js @@ -46,6 +46,7 @@ describe('sendTelegram', () => { }); it('handles errors when sending a Telegram message', async () => { + jest.useFakeTimers(); const mockResponse = { ok: false, status: 400, @@ -56,41 +57,288 @@ describe('sendTelegram', () => { }; global.fetch.mockResolvedValue(mockResponse); - await expect( - sendTelegram({ - chatId, - message, - botToken, - senderName, - }) - ).rejects.toThrow( - Error( + const sendPromise = sendTelegram({ + chatId, + message, + botToken, + senderName, + }); + + // Run all timers to completion + const timerPromise = jest.runAllTimersAsync(); + + // Wait for both the timers and the promise to settle + await Promise.all([ + timerPromise, + expect(sendPromise).rejects.toThrow( 'Failed to send Telegram message, bot API answered with status: 400' - ) - ); + ), + ]); + + jest.useRealTimers(); }); it('handles network errors', async () => { + jest.useFakeTimers(); global.fetch.mockRejectedValue(new Error('Network error')); - await expect( - sendTelegram({ - chatId, - message, - botToken, - senderName, - }) - ).rejects.toThrow('Failed to reach Telegram bot API'); + + const sendPromise = sendTelegram({ + chatId, + message, + botToken, + senderName, + maxRetries: 0, // No retries for this test + }); + + // Run all timers to completion + const timerPromise = jest.runAllTimersAsync(); + + // Wait for both the timers and the promise to settle + await Promise.all([ + timerPromise, + expect(sendPromise).rejects.toThrow('Failed to reach Telegram bot API'), + ]); + + jest.useRealTimers(); }); it('should not throw an error when sender name is undefined', async () => { + jest.useFakeTimers(); const mockResponse = { ok: true, json: jest.fn().mockResolvedValue({ ok: true, result: {} }), }; global.fetch.mockResolvedValue(mockResponse); - await expect( - sendTelegram({ chatId, message, botToken }) - ).resolves.not.toThrow(); + const sendPromise = sendTelegram({ chatId, message, botToken }); + + // Fast-forward time by 1 second to skip the delay + await jest.advanceTimersByTimeAsync(1000); + + await expect(sendPromise).resolves.not.toThrow(); + jest.useRealTimers(); + }); + + it('should wait 1 second before each API call', async () => { + jest.useFakeTimers(); + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ ok: true, result: {} }), + }; + global.fetch.mockResolvedValue(mockResponse); + + const sendPromise = sendTelegram({ + chatId, + message, + botToken, + senderName, + }); + + // Verify fetch is not called before 1 second + expect(global.fetch).not.toHaveBeenCalled(); + + // Fast-forward time by 1 second to skip the delay + await jest.advanceTimersByTimeAsync(1000); + + // Wait for the promise to resolve + await sendPromise; + + expect(global.fetch).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + it('should retry on 429 error with Retry-After header and succeed', async () => { + jest.useFakeTimers(); + const mockHeaders = new Map(); + mockHeaders.set('Retry-After', '2'); + const mockHeadersGet = jest.fn((key) => mockHeaders.get(key)); + + const failedResponse = { + ok: false, + status: 429, + headers: { + get: mockHeadersGet, + }, + }; + const successResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ ok: true, result: {} }), + }; + + global.fetch + .mockResolvedValueOnce(failedResponse) + .mockResolvedValueOnce(successResponse); + + const sendPromise = sendTelegram({ + chatId, + message, + botToken, + senderName, + }); + + // Fast-forward time: 1s (initial delay) + 2s (Retry-After) + 1s (delay before retry) + await jest.advanceTimersByTimeAsync(1000); // Initial delay + await jest.advanceTimersByTimeAsync(2000); // Retry-After delay + await jest.advanceTimersByTimeAsync(1000); // Delay before retry + + await expect(sendPromise).resolves.not.toThrow(); + expect(global.fetch).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + + it('should retry on 429 error with exponential backoff when no Retry-After header and succeed', async () => { + jest.useFakeTimers(); + const mockHeaders = new Map(); + // eslint-disable-next-line sonarjs/no-empty-collection + const mockHeadersGet = jest.fn((key) => mockHeaders.get(key)); + + const failedResponse = { + ok: false, + status: 429, + headers: { + get: mockHeadersGet, + }, + }; + const successResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ ok: true, result: {} }), + }; + + global.fetch + .mockResolvedValueOnce(failedResponse) + .mockResolvedValueOnce(successResponse); + + const sendPromise = sendTelegram({ + chatId, + message, + botToken, + senderName, + initialDelay: 100, + }); + + // Fast-forward time: 1s (initial delay) + 100ms (exponential backoff) + 1s (delay before retry) + await jest.advanceTimersByTimeAsync(1000); // Initial delay + await jest.advanceTimersByTimeAsync(100); // Exponential backoff (100ms for attempt 0) + await jest.advanceTimersByTimeAsync(1000); // Delay before retry + + await expect(sendPromise).resolves.not.toThrow(); + expect(global.fetch).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + + it('should throw error after max retries on 429 error', async () => { + jest.useFakeTimers(); + const mockHeaders = new Map(); + // eslint-disable-next-line sonarjs/no-empty-collection + const mockHeadersGet = jest.fn((key) => mockHeaders.get(key)); + + const failedResponse = { + ok: false, + status: 429, + headers: { + get: mockHeadersGet, + }, + }; + + global.fetch.mockResolvedValue(failedResponse); + + const sendPromise = sendTelegram({ + chatId, + message, + botToken, + senderName, + maxRetries: 2, + initialDelay: 10, + }); + + // Run all timers to completion + const timerPromise = jest.runAllTimersAsync(); + + // Wait for both the timers and the promise to settle + await Promise.all([ + timerPromise, + expect(sendPromise).rejects.toThrow( + 'Failed to send Telegram message: Rate limit exceeded after 3 attempts' + ), + ]); + + expect(global.fetch).toHaveBeenCalledTimes(3); // initial + 2 retries + jest.useRealTimers(); + }); + + it('should retry on network errors with exponential backoff and succeed', async () => { + jest.useFakeTimers(); + const successResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ ok: true, result: {} }), + }; + + global.fetch + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce(successResponse); + + const sendPromise = sendTelegram({ + chatId, + message, + botToken, + senderName, + initialDelay: 100, + }); + + // Run all timers to completion + const timerPromise = jest.runAllTimersAsync(); + + // Wait for both the timers and the promise to settle + await Promise.all([ + timerPromise, + expect(sendPromise).resolves.not.toThrow(), + ]); + + expect(global.fetch).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + + it('should apply 1 second delay before each retry attempt', async () => { + jest.useFakeTimers(); + const mockHeaders = new Map(); + // eslint-disable-next-line sonarjs/no-empty-collection + const mockHeadersGet = jest.fn((key) => mockHeaders.get(key)); + + const failedResponse = { + ok: false, + status: 429, + headers: { + get: mockHeadersGet, + }, + }; + const successResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ ok: true, result: {} }), + }; + + global.fetch + .mockResolvedValueOnce(failedResponse) + .mockResolvedValueOnce(failedResponse) + .mockResolvedValueOnce(successResponse); + + const sendPromise = sendTelegram({ + chatId, + message, + botToken, + senderName, + initialDelay: 100, + }); + + // Run all timers to completion + const timerPromise = jest.runAllTimersAsync(); + + // Wait for both the timers and the promise to settle + await Promise.all([ + timerPromise, + expect(sendPromise).resolves.not.toThrow(), + ]); + + expect(global.fetch).toHaveBeenCalledTimes(3); + jest.useRealTimers(); }); });