From 721fa3db7d80cf49e75bee8ede46240346eeee95 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Mon, 18 May 2026 21:02:38 +0700 Subject: [PATCH] feat: implement auto-retry for Telegram API requests on error 429 and update documentation --- CLAUDE.md | 4 ++- src/utils/rateLimit.ts | 45 ++------------------------- src/utils/telegram.ts | 44 +++++++++++++++++++-------- test/rateLimit.test.ts | 69 +++--------------------------------------- test/telegram.test.ts | 30 ++++++++++++++++++ 5 files changed, 70 insertions(+), 122 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 764c1dd..183c26f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,10 +18,12 @@ Default to using Bun instead of Node.js. - `WebSocket` is built-in. Don't use `ws`. - Prefer `Bun.file` over `node:fs`'s readFile/writeFile - Bun.$`ls` instead of execa. +- Rate limiter lokal dinonaktifkan (`checkRateLimit` di `src/utils/rateLimit.ts` selalu mengembalikan `true`). +- Telegram API memiliki auto-retry otomatis jika mengembalikan error 429 (Too Many Requests) menggunakan helper `withRetry` di `src/utils/telegram.ts`. ## Testing -Use `bun test` to run tests. +Use `bun test` to run tests. Jalankan tes secara spesifik (misal `bun test test/rateLimit.test.ts`) untuk menghindari polusi mock antar berkas tes ketika dijalankan bersamaan. ```ts#index.test.ts import { test, expect } from "bun:test"; diff --git a/src/utils/rateLimit.ts b/src/utils/rateLimit.ts index 86e98ed..1db174f 100644 --- a/src/utils/rateLimit.ts +++ b/src/utils/rateLimit.ts @@ -1,48 +1,7 @@ -import logger from './logger'; - -interface RateLimitRecord { - count: number; - reset: number; -} - -const rateLimitMap = new Map(); - -export const checkRateLimit = (key: string): boolean => { - const now = Date.now(); - const windowMs = parseInt(process.env.RATE_LIMIT_WINDOW_MS!, 10) || 60000; - const maxRequests = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS!, 10) || 30; - - if (!rateLimitMap.has(key)) { - rateLimitMap.set(key, { count: 0, reset: now + windowMs }); - } - - const record = rateLimitMap.get(key)!; - - if (now > record.reset) { - record.count = 0; - record.reset = now + windowMs; - } - - if (record.count >= maxRequests) { - logger.warn('Rate limit exceeded', { key, count: record.count, reset: record.reset }); - return false; - } - - record.count++; +export const checkRateLimit = (_key: string): boolean => { return true; }; export const cleanupRateLimitCache = (): void => { - const now = Date.now(); - const keysToDelete: string[] = []; - - for (const [key, record] of rateLimitMap.entries()) { - if (now > record.reset) { - keysToDelete.push(key); - } - } - - for (const key of keysToDelete) { - rateLimitMap.delete(key); - } + // No-op karena rate limit dinonaktifkan }; diff --git a/src/utils/telegram.ts b/src/utils/telegram.ts index eb3a696..8c4f631 100644 --- a/src/utils/telegram.ts +++ b/src/utils/telegram.ts @@ -5,6 +5,24 @@ import logger from './logger'; const bot = new Telegraf(config.botToken); const TELEGRAM_API_URL = `https://api.telegram.org/bot${config.botToken}/`; +const withRetry = async (fn: () => Promise, retries = 5): Promise => { + try { + return await fn(); + } catch (error: any) { + const errorStr = error.message || String(error); + const match = errorStr.match(/retry after (\d+)/i); + if (match && retries > 0) { + const seconds = parseInt(match[1], 10); + logger.warn(`Telegram 429 Too Many Requests detected. Retrying after ${seconds} seconds...`, { + error: errorStr, + }); + await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); + return withRetry(fn, retries - 1); + } + throw error; + } +}; + interface ForwardResult { telegramFileId: string; telegramFileUniqueId: string; @@ -27,31 +45,31 @@ export const forwardToStorage = async ( let result: any; if (fileType === 'photo') { - result = await bot.telegram.sendPhoto(config.storageChatId, filePayload, { + result = await withRetry(() => bot.telegram.sendPhoto(config.storageChatId, filePayload, { caption: fileName, - }); + })); } else if (fileType === 'audio') { - result = await bot.telegram.sendAudio(config.storageChatId, filePayload, { + result = await withRetry(() => bot.telegram.sendAudio(config.storageChatId, filePayload, { caption: fileName, - }); + })); } else if (fileType === 'video') { - result = await bot.telegram.sendVideo(config.storageChatId, filePayload, { + result = await withRetry(() => bot.telegram.sendVideo(config.storageChatId, filePayload, { caption: fileName, - }); + })); } else if (fileType === 'voice') { - result = await bot.telegram.sendVoice(config.storageChatId, filePayload, { + result = await withRetry(() => bot.telegram.sendVoice(config.storageChatId, filePayload, { caption: fileName, - }); + })); } else if (fileType === 'animation') { - result = await bot.telegram.sendAnimation(config.storageChatId, filePayload, { + result = await withRetry(() => bot.telegram.sendAnimation(config.storageChatId, filePayload, { caption: fileName, - }); + })); } else if (fileType === 'sticker') { - result = await bot.telegram.sendSticker(config.storageChatId, filePayload); + result = await withRetry(() => bot.telegram.sendSticker(config.storageChatId, filePayload)); } else { - result = await bot.telegram.sendDocument(config.storageChatId, filePayload, { + result = await withRetry(() => bot.telegram.sendDocument(config.storageChatId, filePayload, { caption: `📁 ${fileName}`, - }); + })); } let uploadedFile: any; diff --git a/test/rateLimit.test.ts b/test/rateLimit.test.ts index 6450452..da1c48f 100644 --- a/test/rateLimit.test.ts +++ b/test/rateLimit.test.ts @@ -9,79 +9,18 @@ const warnSpy = spyOn(logger, 'warn'); describe('Rate Limiter', () => { beforeEach(() => { warnSpy.mockClear(); - // Set custom env variables for predictable tests - process.env.RATE_LIMIT_WINDOW_MS = '100'; // 100ms window - process.env.RATE_LIMIT_MAX_REQUESTS = '3'; // max 3 requests }); - afterEach(() => { - delete process.env.RATE_LIMIT_WINDOW_MS; - delete process.env.RATE_LIMIT_MAX_REQUESTS; - }); - - it('should allow requests under the limit', () => { + it('should always allow requests as rate limiter is disabled', () => { const key = 'user-1'; expect(checkRateLimit(key)).toBe(true); expect(checkRateLimit(key)).toBe(true); expect(checkRateLimit(key)).toBe(true); + expect(checkRateLimit(key)).toBe(true); expect(warnSpy).not.toHaveBeenCalled(); }); - it('should block requests exceeding the limit and log a warning', () => { - const key = 'user-2'; - expect(checkRateLimit(key)).toBe(true); - expect(checkRateLimit(key)).toBe(true); - expect(checkRateLimit(key)).toBe(true); - - // 4th request exceeds limit of 3 - expect(checkRateLimit(key)).toBe(false); - expect(warnSpy).toHaveBeenCalled(); - const callArgs = warnSpy.mock.calls[0]; - expect(callArgs[0]).toBe('Rate limit exceeded'); - expect(callArgs[1].key).toBe(key); - }); - - it('should reset request count after the window passes', async () => { - const key = 'user-3'; - expect(checkRateLimit(key)).toBe(true); - expect(checkRateLimit(key)).toBe(true); - expect(checkRateLimit(key)).toBe(true); - expect(checkRateLimit(key)).toBe(false); // blocked - - // Wait for window to expire (100ms) - await new Promise((resolve) => setTimeout(resolve, 110)); - - // Should be allowed again - expect(checkRateLimit(key)).toBe(true); - }); - - it('should cleanup rate limit cache of expired keys', async () => { - const key1 = 'cleanup-1'; - const key2 = 'cleanup-2'; - - // Populate keys - expect(checkRateLimit(key1)).toBe(true); - expect(checkRateLimit(key2)).toBe(true); - - // Run cleanup immediately (none should be expired yet as 100ms hasn't passed) - cleanupRateLimitCache(); - - // Verify still tracked (counts shouldn't reset, e.g., if we consume remaining limits) - expect(checkRateLimit(key1)).toBe(true); // request 2 - expect(checkRateLimit(key1)).toBe(true); // request 3 - expect(checkRateLimit(key1)).toBe(false); // request 4 (blocked) - - // Wait for window to expire - await new Promise((resolve) => setTimeout(resolve, 110)); - - // Run cleanup - cleanupRateLimitCache(); - - // Since they were deleted from the map, they should be initialized as new records - // If they were cleaned up, we should be able to do 3 requests again - expect(checkRateLimit(key1)).toBe(true); // 1 - expect(checkRateLimit(key1)).toBe(true); // 2 - expect(checkRateLimit(key1)).toBe(true); // 3 - expect(checkRateLimit(key1)).toBe(false); // 4 (blocked) + it('should no-op on cleanup', () => { + expect(() => cleanupRateLimitCache()).not.toThrow(); }); }); diff --git a/test/telegram.test.ts b/test/telegram.test.ts index bda8669..10fe690 100644 --- a/test/telegram.test.ts +++ b/test/telegram.test.ts @@ -115,6 +115,36 @@ describe('Telegram API Utilities', () => { error: 'Telegram send failed', }); }); + + it('should retry when telegram returns 429 Too Many Requests', async () => { + const bot = getBot(); + let calls = 0; + bot.telegram.sendPhoto = mock(() => { + calls++; + if (calls === 1) { + return Promise.reject(new Error('429: Too Many Requests: retry after 1')); + } + return Promise.resolve({ + message_id: 999, + photo: [{ file_id: 'retry_photo_id', file_unique_id: 'retry_unique_id' }], + }); + }); + + const chunk = Buffer.from('fake photo data'); + const fileName = 'test_photo.jpg'; + + const startTime = Date.now(); + const result = await forwardToStorage(chunk, fileName, 'photo'); + const duration = Date.now() - startTime; + + expect(calls).toBe(2); + expect(duration).toBeGreaterThanOrEqual(1000); + expect(result).toEqual({ + telegramFileId: 'retry_photo_id', + telegramFileUniqueId: 'retry_unique_id', + storageMessageId: 999, + }); + }); }); describe('getFileInfo', () => {