feat: implement caching for file info, add rate limiting, and enhance upload handling with retry logic

This commit is contained in:
MythEclipse
2026-05-21 23:31:49 +07:00
parent 10c968cf01
commit fd5eb98586
10 changed files with 557 additions and 11 deletions
+20 -2
View File
@@ -3,6 +3,7 @@ import { formatCreatedAt, getErrorMessage } from '../utils/file';
import logger from '../utils/logger';
import { checkRateLimit } from '../utils/rateLimit';
import { getBot } from '../utils/telegram';
import { fileInfoCache } from '../utils/cache';
type RequestWithParams = Request & {
params?: {
@@ -26,8 +27,25 @@ export const handleFileRedirect = async (req: RequestWithParams): Promise<Respon
return Response.json({ error: 'File not found' }, { status: 404 });
}
const bot = getBot();
const fileInfo = await bot.telegram.getFile(file.telegramFileId);
// Check cache first
const cacheKey = `file_info_${file.telegramFileId}`;
let fileInfo = fileInfoCache.get(cacheKey);
if (!fileInfo) {
// Cache miss - fetch from Telegram API
const bot = getBot();
const apiFileInfo = await bot.telegram.getFile(file.telegramFileId);
fileInfo = {
file_size: (apiFileInfo as any).file_size || 0,
mime_type: (apiFileInfo as any).mime_type || 'application/octet-stream',
file_path: (apiFileInfo as any).file_path || '',
};
// Store in cache
fileInfoCache.set(cacheKey, fileInfo);
logger.debug('File info cached', { public_id, cacheKey });
} else {
logger.debug('File info from cache', { public_id, cacheKey });
}
const redirectUrl = `https://api.telegram.org/file/bot${process.env.BOT_TOKEN}/${fileInfo.file_path}`;
return new Response(null, {
+8 -5
View File
@@ -1,4 +1,5 @@
import { createReadStream, unlinkSync } from 'node:fs';
import { createReadStream } from 'node:fs';
import { unlink } from 'node:fs/promises';
import { nanoid } from 'nanoid';
import { db, files as fileSchema } from '../db';
import { findFileByHash } from '../db/files';
@@ -69,11 +70,13 @@ const performUpload = async (
updatedAt: new Date(),
};
} finally {
setTimeout(() => {
setTimeout(async () => {
try {
unlinkSync(tempPath);
} catch {}
}, 50);
await unlink(tempPath);
} catch (err) {
logger.warn('Failed to cleanup temp file', { tempPath, error: getErrorMessage(err) });
}
}, 500);
}
};
+129
View File
@@ -0,0 +1,129 @@
import logger from './logger';
interface BotHealth {
index: number;
isHealthy: boolean;
rateLimitedUntil: number;
failureCount: number;
successCount: number;
lastUsed: number;
}
class BotHealthTracker {
private botHealth: Map<number, BotHealth> = new Map();
private totalBots: number;
constructor(totalBots: number) {
this.totalBots = totalBots;
for (let i = 0; i < totalBots; i++) {
this.botHealth.set(i, {
index: i,
isHealthy: true,
rateLimitedUntil: 0,
failureCount: 0,
successCount: 0,
lastUsed: 0,
});
}
}
recordSuccess(botIndex: number): void {
const health = this.botHealth.get(botIndex);
if (health) {
health.successCount++;
health.failureCount = 0;
health.isHealthy = true;
health.lastUsed = Date.now();
}
}
recordFailure(botIndex: number, retryAfterSeconds?: number): void {
const health = this.botHealth.get(botIndex);
if (health) {
health.failureCount++;
health.lastUsed = Date.now();
if (retryAfterSeconds) {
health.rateLimitedUntil = Date.now() + retryAfterSeconds * 1000;
health.isHealthy = false;
logger.warn('Bot rate limited', {
botIndex,
retryAfter: retryAfterSeconds,
});
} else if (health.failureCount >= 3) {
health.isHealthy = false;
logger.warn('Bot marked unhealthy', { botIndex, failures: health.failureCount });
}
}
}
getHealthiestBot(): number {
const now = Date.now();
let bestBot = 0;
let bestScore = -Infinity;
for (let i = 0; i < this.totalBots; i++) {
const health = this.botHealth.get(i)!;
// Skip rate-limited bots
if (health.rateLimitedUntil > now) {
continue;
}
// Calculate score: prefer healthy bots with fewer failures and more successes
const score =
(health.isHealthy ? 100 : 0) +
health.successCount -
health.failureCount * 10 -
(now - health.lastUsed) / 1000;
if (score > bestScore) {
bestScore = score;
bestBot = i;
}
}
return bestBot;
}
getStats() {
const stats = {
healthy: 0,
rateLimited: 0,
unhealthy: 0,
bots: [] as any[],
};
const now = Date.now();
for (const health of this.botHealth.values()) {
if (health.rateLimitedUntil > now) {
stats.rateLimited++;
} else if (health.isHealthy) {
stats.healthy++;
} else {
stats.unhealthy++;
}
stats.bots.push({
index: health.index,
healthy: health.isHealthy,
rateLimitedUntil: health.rateLimitedUntil > now ? health.rateLimitedUntil - now : 0,
failures: health.failureCount,
successes: health.successCount,
});
}
return stats;
}
reset(): void {
for (const health of this.botHealth.values()) {
health.isHealthy = true;
health.rateLimitedUntil = 0;
health.failureCount = 0;
health.successCount = 0;
}
}
}
export { BotHealthTracker };
+81
View File
@@ -0,0 +1,81 @@
// Simple in-memory cache with TTL support
interface CacheEntry<T> {
value: T;
expiresAt: number;
}
class Cache<T> {
private store = new Map<string, CacheEntry<T>>();
private ttlMs: number;
constructor(ttlSeconds: number = 3600) {
this.ttlMs = ttlSeconds * 1000;
}
set(key: string, value: T): void {
this.store.set(key, {
value,
expiresAt: Date.now() + this.ttlMs,
});
}
get(key: string): T | null {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value;
}
has(key: string): boolean {
return this.get(key) !== null;
}
delete(key: string): void {
this.store.delete(key);
}
clear(): void {
this.store.clear();
}
size(): number {
return this.store.size;
}
// Cleanup expired entries
cleanup(): number {
let removed = 0;
const now = Date.now();
for (const [key, entry] of this.store.entries()) {
if (now > entry.expiresAt) {
this.store.delete(key);
removed++;
}
}
return removed;
}
}
// File info cache (1 hour TTL)
export const fileInfoCache = new Cache<{
file_size: number;
mime_type: string;
file_path: string;
}>(3600);
// Cleanup expired cache entries every 5 minutes
setInterval(() => {
const removed = fileInfoCache.cleanup();
if (removed > 0) {
console.log(`Cleaned up ${removed} expired cache entries`);
}
}, 5 * 60 * 1000);
export { Cache };
+122
View File
@@ -0,0 +1,122 @@
import logger from './logger';
interface Metric {
name: string;
value: number;
timestamp: number;
tags?: Record<string, string>;
}
interface MetricsSnapshot {
uploadLatency: { p50: number; p95: number; p99: number };
uploadThroughput: number;
queueSize: number;
errorRate: number;
cacheHitRate: number;
botUtilization: number;
timestamp: number;
}
class MetricsCollector {
private metrics: Metric[] = [];
private uploadTimes: number[] = [];
private errorCount = 0;
private totalRequests = 0;
private cacheHits = 0;
private cacheMisses = 0;
private maxMetricsSize = 10000;
recordUploadTime(durationMs: number): void {
this.uploadTimes.push(durationMs);
this.totalRequests++;
// Keep only last 1000 measurements
if (this.uploadTimes.length > 1000) {
this.uploadTimes.shift();
}
}
recordError(): void {
this.errorCount++;
}
recordCacheHit(): void {
this.cacheHits++;
}
recordCacheMiss(): void {
this.cacheMisses++;
}
recordMetric(name: string, value: number, tags?: Record<string, string>): void {
this.metrics.push({
name,
value,
timestamp: Date.now(),
tags,
});
// Keep metrics bounded
if (this.metrics.length > this.maxMetricsSize) {
this.metrics = this.metrics.slice(-this.maxMetricsSize);
}
}
private calculatePercentile(arr: number[], percentile: number): number {
if (arr.length === 0) return 0;
const sorted = [...arr].sort((a, b) => a - b);
const index = Math.ceil((percentile / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
getSnapshot(): MetricsSnapshot {
const errorRate = this.totalRequests > 0 ? (this.errorCount / this.totalRequests) * 100 : 0;
const cacheHitRate =
this.cacheHits + this.cacheMisses > 0
? (this.cacheHits / (this.cacheHits + this.cacheMisses)) * 100
: 0;
return {
uploadLatency: {
p50: this.calculatePercentile(this.uploadTimes, 50),
p95: this.calculatePercentile(this.uploadTimes, 95),
p99: this.calculatePercentile(this.uploadTimes, 99),
},
uploadThroughput: this.totalRequests > 0 ? this.totalRequests / 60 : 0,
queueSize: 0, // Will be updated by queue
errorRate,
cacheHitRate,
botUtilization: 0, // Will be updated by bot tracker
timestamp: Date.now(),
};
}
reset(): void {
this.uploadTimes = [];
this.errorCount = 0;
this.totalRequests = 0;
this.cacheHits = 0;
this.cacheMisses = 0;
this.metrics = [];
}
getMetrics(name?: string): Metric[] {
if (!name) return this.metrics;
return this.metrics.filter((m) => m.name === name);
}
}
export const metricsCollector = new MetricsCollector();
// Log metrics every 5 minutes
setInterval(() => {
const snapshot = metricsCollector.getSnapshot();
logger.info('Metrics snapshot', {
uploadLatency: snapshot.uploadLatency,
uploadThroughput: snapshot.uploadThroughput.toFixed(2),
errorRate: snapshot.errorRate.toFixed(2),
cacheHitRate: snapshot.cacheHitRate.toFixed(2),
});
}, 5 * 60 * 1000);
export { MetricsCollector };
+52 -2
View File
@@ -1,7 +1,57 @@
export const checkRateLimit = (_key: string): boolean => {
import logger from './logger';
// Simple sliding window rate limiter
interface RateLimitEntry {
count: number;
resetTime: number;
}
const rateLimitStore = new Map<string, RateLimitEntry>();
const WINDOW_SIZE_MS = 60000; // 1 minute window
const MAX_REQUESTS_PER_WINDOW = 100; // 100 requests per minute per IP
export const checkRateLimit = (key: string): boolean => {
const now = Date.now();
const entry = rateLimitStore.get(key);
// No entry or window expired - create new entry
if (!entry || now > entry.resetTime) {
rateLimitStore.set(key, {
count: 1,
resetTime: now + WINDOW_SIZE_MS,
});
return true;
}
// Check if limit exceeded
if (entry.count >= MAX_REQUESTS_PER_WINDOW) {
logger.warn('Rate limit exceeded', { key, count: entry.count });
return false;
}
// Increment counter
entry.count++;
return true;
};
export const cleanupRateLimitCache = (): void => {
// No-op karena rate limit dinonaktifkan
const now = Date.now();
let cleaned = 0;
for (const [key, entry] of rateLimitStore.entries()) {
if (now > entry.resetTime) {
rateLimitStore.delete(key);
cleaned++;
}
}
if (cleaned > 0) {
logger.debug('Rate limit cache cleanup', { cleaned, remaining: rateLimitStore.size });
}
};
export const getRateLimitStats = () => ({
trackedIPs: rateLimitStore.size,
windowSize: WINDOW_SIZE_MS,
maxRequests: MAX_REQUESTS_PER_WINDOW,
});
+91
View File
@@ -0,0 +1,91 @@
import logger from './logger';
interface RetryOptions {
maxRetries?: number;
initialDelayMs?: number;
maxDelayMs?: number;
backoffMultiplier?: number;
shouldRetry?: (error: unknown) => boolean;
}
const DEFAULT_OPTIONS: Required<RetryOptions> = {
maxRetries: 3,
initialDelayMs: 100,
maxDelayMs: 5000,
backoffMultiplier: 2,
shouldRetry: (error: unknown) => {
const errorStr = error instanceof Error ? error.message : String(error);
// Retry on transient errors
return (
errorStr.includes('ECONNREFUSED') ||
errorStr.includes('ETIMEDOUT') ||
errorStr.includes('ENOTFOUND') ||
errorStr.includes('429') ||
errorStr.includes('timeout')
);
},
};
export const withRetry = async <T>(
fn: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> => {
const opts = { ...DEFAULT_OPTIONS, ...options };
let lastError: unknown;
let delay = opts.initialDelayMs;
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
try {
return await fn();
} catch (error: unknown) {
lastError = error;
const errorStr = error instanceof Error ? error.message : String(error);
if (attempt === opts.maxRetries || !opts.shouldRetry(error)) {
logger.error('Retry exhausted', {
attempt,
maxRetries: opts.maxRetries,
error: errorStr,
});
throw error;
}
logger.warn('Retrying after error', {
attempt,
delay,
error: errorStr,
});
await new Promise((resolve) => setTimeout(resolve, delay));
delay = Math.min(delay * opts.backoffMultiplier, opts.maxDelayMs);
}
}
throw lastError;
};
export const withTimeout = async <T>(
fn: () => Promise<T>,
timeoutMs: number = 30000,
): Promise<T> => {
return Promise.race([
fn(),
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(`Operation timeout after ${timeoutMs}ms`)), timeoutMs),
),
]);
};
export const withFallback = async <T>(
primary: () => Promise<T>,
fallback: () => Promise<T>,
): Promise<T> => {
try {
return await primary();
} catch (error: unknown) {
logger.warn('Primary operation failed, using fallback', {
error: error instanceof Error ? error.message : String(error),
});
return fallback();
}
};
+45 -1
View File
@@ -1,3 +1,47 @@
import PQueue from 'p-queue';
import logger from './logger';
// Create queue with concurrency limit matching bot pool size
// Concurrency: 4-8 uploads in parallel
// Interval: 1 second window for rate limiting
// IntervalCap: Max 10 tasks per second
const uploadQueue = new PQueue({
concurrency: 4,
interval: 1000,
intervalCap: 10,
});
// Monitor queue events
uploadQueue.on('add', () => {
const stats = getQueueStats();
if (stats.size > 5) {
logger.warn('Upload queue building up', { pending: stats.pending, size: stats.size });
}
});
uploadQueue.on('next', () => {
const stats = getQueueStats();
logger.debug('Processing next upload', { pending: stats.pending, size: stats.size });
});
export const enqueueUpload = <T>(task: () => Promise<T>): Promise<T> => {
return task();
return uploadQueue.add(task);
};
export const getQueueStats = () => ({
pending: uploadQueue.pending,
size: uploadQueue.size,
});
export const getQueueSize = (): number => uploadQueue.size;
export const getPendingCount = (): number => uploadQueue.pending;
export const clearQueue = async (): Promise<void> => {
uploadQueue.clear();
await uploadQueue.onIdle();
};
export const waitForQueue = async (): Promise<void> => {
await uploadQueue.onIdle();
};