Files
KVideo/lib/utils/fetch-with-retry.ts
2025-12-24 23:53:24 +08:00

101 lines
4.1 KiB
TypeScript

import { NextRequest } from 'next/server';
interface FetchWithRetryOptions {
url: string;
request: NextRequest;
headers?: Record<string, string>;
}
export async function fetchWithRetry({ url, request, headers = {} }: FetchWithRetryOptions): Promise<Response> {
// User-Agent rotation for better compatibility
const userAgents = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0'
];
const randomUA = userAgents[Math.floor(Math.random() * userAgents.length)];
// Smart Referer: use video domain instead of kvideo.vercel.app to avoid suspicion
const videoUrl = new URL(url);
const referer = request.nextUrl.searchParams.get('referer') || `${videoUrl.protocol}//${videoUrl.hostname}`;
// Optional IP forwarding (default: Beijing IP)
const forwardedIP = request.nextUrl.searchParams.get('ip') || '202.108.22.5';
const MAX_RETRIES = 5;
const TIMEOUT_MS = 30000; // 30 seconds
let lastError: unknown = null;
let response: Response | null = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
// Exponential backoff: 100ms, 200ms, 400ms, 800ms, 1600ms
const backoffDelay = attempt > 1 ? Math.pow(2, attempt - 2) * 100 : 0;
if (backoffDelay > 0) {
await new Promise(resolve => setTimeout(resolve, backoffDelay));
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
response = await fetch(url, {
headers: {
...headers, // First: forwarded headers (Cookie, Range)
// Then override with anti-blocking headers (these take precedence)
'User-Agent': randomUA,
'Accept': '*/*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'X-Forwarded-For': forwardedIP,
'Client-IP': forwardedIP,
'Referer': referer,
'Origin': `${videoUrl.protocol}//${videoUrl.hostname}`,
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'cross-site',
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
break;
}
if (response.status === 503 && attempt < MAX_RETRIES) {
console.warn(`⚠ Got 503 on attempt ${attempt}, retrying with backoff ${backoffDelay}ms...`);
lastError = `503 on attempt ${attempt}`;
continue;
}
console.warn(`✗ Got ${response.status} on attempt ${attempt}`);
break;
} catch (fetchError) {
lastError = fetchError;
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
console.warn(`⚠ Timeout on attempt ${attempt}, retrying...`);
} else if (attempt < MAX_RETRIES) {
console.warn(`⚠ Fetch error on attempt ${attempt}, retrying...`, fetchError);
} else {
throw fetchError;
}
}
}
// If we got a response (even an error response like 403, 404), return it
// Only throw if we truly failed to get any response
if (!response) {
throw new Error(`Failed after ${MAX_RETRIES} attempts: ${lastError}`);
}
// Return the response even if it's an error status (403, 404, etc.)
// The caller can check response.ok or response.status
if (!response.ok) {
console.warn(`⚠ Returning non-OK response: ${response.status} ${response.statusText}`);
}
return response;
}