feat: implement auto-retry for Telegram API requests on error 429 and update documentation

This commit is contained in:
MythEclipse
2026-05-18 21:02:38 +07:00
parent 91cfee0b37
commit 721fa3db7d
5 changed files with 70 additions and 122 deletions
+3 -1
View File
@@ -18,10 +18,12 @@ Default to using Bun instead of Node.js.
- `WebSocket` is built-in. Don't use `ws`. - `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile - Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa. - 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 ## 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 ```ts#index.test.ts
import { test, expect } from "bun:test"; import { test, expect } from "bun:test";
+2 -43
View File
@@ -1,48 +1,7 @@
import logger from './logger'; export const checkRateLimit = (_key: string): boolean => {
interface RateLimitRecord {
count: number;
reset: number;
}
const rateLimitMap = new Map<string, RateLimitRecord>();
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++;
return true; return true;
}; };
export const cleanupRateLimitCache = (): void => { export const cleanupRateLimitCache = (): void => {
const now = Date.now(); // No-op karena rate limit dinonaktifkan
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);
}
}; };
+31 -13
View File
@@ -5,6 +5,24 @@ import logger from './logger';
const bot = new Telegraf(config.botToken); const bot = new Telegraf(config.botToken);
const TELEGRAM_API_URL = `https://api.telegram.org/bot${config.botToken}/`; const TELEGRAM_API_URL = `https://api.telegram.org/bot${config.botToken}/`;
const withRetry = async <T>(fn: () => Promise<T>, retries = 5): Promise<T> => {
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 { interface ForwardResult {
telegramFileId: string; telegramFileId: string;
telegramFileUniqueId: string; telegramFileUniqueId: string;
@@ -27,31 +45,31 @@ export const forwardToStorage = async (
let result: any; let result: any;
if (fileType === 'photo') { if (fileType === 'photo') {
result = await bot.telegram.sendPhoto(config.storageChatId, filePayload, { result = await withRetry(() => bot.telegram.sendPhoto(config.storageChatId, filePayload, {
caption: fileName, caption: fileName,
}); }));
} else if (fileType === 'audio') { } else if (fileType === 'audio') {
result = await bot.telegram.sendAudio(config.storageChatId, filePayload, { result = await withRetry(() => bot.telegram.sendAudio(config.storageChatId, filePayload, {
caption: fileName, caption: fileName,
}); }));
} else if (fileType === 'video') { } else if (fileType === 'video') {
result = await bot.telegram.sendVideo(config.storageChatId, filePayload, { result = await withRetry(() => bot.telegram.sendVideo(config.storageChatId, filePayload, {
caption: fileName, caption: fileName,
}); }));
} else if (fileType === 'voice') { } else if (fileType === 'voice') {
result = await bot.telegram.sendVoice(config.storageChatId, filePayload, { result = await withRetry(() => bot.telegram.sendVoice(config.storageChatId, filePayload, {
caption: fileName, caption: fileName,
}); }));
} else if (fileType === 'animation') { } else if (fileType === 'animation') {
result = await bot.telegram.sendAnimation(config.storageChatId, filePayload, { result = await withRetry(() => bot.telegram.sendAnimation(config.storageChatId, filePayload, {
caption: fileName, caption: fileName,
}); }));
} else if (fileType === 'sticker') { } else if (fileType === 'sticker') {
result = await bot.telegram.sendSticker(config.storageChatId, filePayload); result = await withRetry(() => bot.telegram.sendSticker(config.storageChatId, filePayload));
} else { } else {
result = await bot.telegram.sendDocument(config.storageChatId, filePayload, { result = await withRetry(() => bot.telegram.sendDocument(config.storageChatId, filePayload, {
caption: `📁 ${fileName}`, caption: `📁 ${fileName}`,
}); }));
} }
let uploadedFile: any; let uploadedFile: any;
+4 -65
View File
@@ -9,79 +9,18 @@ const warnSpy = spyOn(logger, 'warn');
describe('Rate Limiter', () => { describe('Rate Limiter', () => {
beforeEach(() => { beforeEach(() => {
warnSpy.mockClear(); 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(() => { it('should always allow requests as rate limiter is disabled', () => {
delete process.env.RATE_LIMIT_WINDOW_MS;
delete process.env.RATE_LIMIT_MAX_REQUESTS;
});
it('should allow requests under the limit', () => {
const key = 'user-1'; 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(checkRateLimit(key)).toBe(true); expect(checkRateLimit(key)).toBe(true);
expect(checkRateLimit(key)).toBe(true);
expect(warnSpy).not.toHaveBeenCalled(); expect(warnSpy).not.toHaveBeenCalled();
}); });
it('should block requests exceeding the limit and log a warning', () => { it('should no-op on cleanup', () => {
const key = 'user-2'; expect(() => cleanupRateLimitCache()).not.toThrow();
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)
}); });
}); });
+30
View File
@@ -115,6 +115,36 @@ describe('Telegram API Utilities', () => {
error: 'Telegram send failed', 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', () => { describe('getFileInfo', () => {