feat: auto-rotate proxy pool with retry on network failure
- Add src/lib/proxy-pool.ts: round-robin proxy pool that reads proxy.txt (host:port:user:pass), tracks consecutive failures, and rotates on threshold - Integrate into src/index.ts: on fetch network error, mark proxy as failed, rotate to next, and retry the request once - proxy.txt loaded from PROXY_FILE/PROXY_LIST env var or ./proxy.txt - Graceful no-op when proxy.txt doesn't exist (Vercel/Workers) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3f96d8a6e7
commit
03427cae8c
@@ -0,0 +1,10 @@
|
||||
38.154.203.95:5863:eoimpusi:27ije2xpgvl9
|
||||
198.105.121.200:6462:eoimpusi:27ije2xpgvl9
|
||||
64.137.96.74:6641:eoimpusi:27ije2xpgvl9
|
||||
209.127.138.10:5784:eoimpusi:27ije2xpgvl9
|
||||
38.154.185.97:6370:eoimpusi:27ije2xpgvl9
|
||||
84.247.60.125:6095:eoimpusi:27ije2xpgvl9
|
||||
142.111.67.146:5611:eoimpusi:27ije2xpgvl9
|
||||
191.96.254.138:6185:eoimpusi:27ije2xpgvl9
|
||||
31.58.9.4:6077:eoimpusi:27ije2xpgvl9
|
||||
104.239.107.47:5699:eoimpusi:27ije2xpgvl9
|
||||
+45
-16
@@ -27,6 +27,7 @@ import {
|
||||
import { checkBodySize } from "./middleware/body-limiter";
|
||||
import { createRateLimiter } from "./middleware/rate-limiter";
|
||||
import { logRelayEvent } from "./middleware/logger";
|
||||
import { ProxyPool } from "./lib/proxy-pool";
|
||||
|
||||
import type { Server, ServerWebSocket } from "bun";
|
||||
|
||||
@@ -50,6 +51,13 @@ const rateLimiter = createRateLimiter({
|
||||
),
|
||||
});
|
||||
|
||||
// ─── Proxy pool (optional) ───────────────────────────────────────────────────────
|
||||
|
||||
const proxyPool = new ProxyPool();
|
||||
proxyPool.tryLoad(
|
||||
process.env.PROXY_FILE || process.env.PROXY_LIST || "./proxy.txt",
|
||||
);
|
||||
|
||||
// ─── WebSocket relay data type ──────────────────────────────────────────────────
|
||||
|
||||
interface WSRelayData {
|
||||
@@ -340,28 +348,49 @@ async function handleRelay(
|
||||
req,
|
||||
filteredHeaders,
|
||||
RELAY_TIMEOUT_MS,
|
||||
);
|
||||
) as RequestInit & { proxy?: string };
|
||||
|
||||
const targetUrlString = targetUrl.toString();
|
||||
|
||||
// ── Execute upstream fetch ───────────────────────────────────────
|
||||
// ── Attach proxy (if pool is loaded) ────────────────────────────
|
||||
const proxyUrl = proxyPool.getProxyUrl();
|
||||
if (proxyUrl) fetchOptions.proxy = proxyUrl;
|
||||
|
||||
// ── Execute upstream fetch (with proxy retry on failure) ─────────
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(targetUrlString, fetchOptions);
|
||||
} catch (err) {
|
||||
const classified = classifyFetchError(err);
|
||||
logRelayEvent({
|
||||
method,
|
||||
url: requestUrl,
|
||||
status: classified.status,
|
||||
durationMs: Math.round(performance.now() - startTime),
|
||||
error: classified.message,
|
||||
targetUrl: targetUrlString,
|
||||
ip: clientIP,
|
||||
});
|
||||
return createErrorResponse(classified);
|
||||
let retried = false;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
response = await fetch(targetUrlString, fetchOptions);
|
||||
break;
|
||||
} catch (err) {
|
||||
// Rotate proxy on network failure and retry once
|
||||
if (proxyPool.size > 0 && !retried) {
|
||||
retried = true;
|
||||
const next = proxyPool.markFailed();
|
||||
if (next) {
|
||||
fetchOptions.proxy = proxyPool.getProxyUrl()!;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const classified = classifyFetchError(err);
|
||||
logRelayEvent({
|
||||
method,
|
||||
url: requestUrl,
|
||||
status: classified.status,
|
||||
durationMs: Math.round(performance.now() - startTime),
|
||||
error: classified.message,
|
||||
targetUrl: targetUrlString,
|
||||
ip: clientIP,
|
||||
});
|
||||
return createErrorResponse(classified);
|
||||
}
|
||||
}
|
||||
|
||||
if (proxyUrl) proxyPool.markSuccess();
|
||||
|
||||
// ── Build relay response ─────────────────────────────────────────
|
||||
const relayedResponse = createRelayResponse(response);
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Auto-rotating proxy pool.
|
||||
*
|
||||
* Reads proxies from a text file (format: host:port:username:password),
|
||||
* rotates through them round-robin, and tracks failures so unhealthy
|
||||
* proxies are skipped.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProxyEntry {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// ─── ProxyPool ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export class ProxyPool {
|
||||
private proxies: ProxyEntry[] = [];
|
||||
private currentIndex = 0;
|
||||
private failureThreshold = 3;
|
||||
/** host:port → consecutive failure count */
|
||||
private failures = new Map<string, number>();
|
||||
|
||||
// ── Load ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load proxies from a file at `filePath`.
|
||||
*
|
||||
* Expected format — one proxy per line:
|
||||
* host:port:username:password
|
||||
*
|
||||
* Lines starting with `#` are ignored as comments.
|
||||
*/
|
||||
load(filePath: string): void {
|
||||
if (!existsSync(filePath)) {
|
||||
console.warn(`[proxy-pool] File not found: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = readFileSync(filePath, "utf-8");
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.map((l: string) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const parsed: ProxyEntry[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("#")) continue;
|
||||
|
||||
const parts = line.split(":");
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
const host = parts[0]!;
|
||||
const port = Number.parseInt(parts[1]!, 10);
|
||||
if (!Number.isFinite(port)) continue;
|
||||
|
||||
parsed.push({
|
||||
host,
|
||||
port,
|
||||
username: parts[2] ?? "",
|
||||
password: parts.slice(3).join(":") ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
this.proxies = parsed;
|
||||
this.currentIndex = 0;
|
||||
this.failures.clear();
|
||||
|
||||
if (this.proxies.length > 0) {
|
||||
console.log(
|
||||
`[proxy-pool] Loaded ${this.proxies.length} proxies from ${filePath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience — load from a path or skip.
|
||||
* Returns `true` if proxies were loaded.
|
||||
*/
|
||||
tryLoad(filePath?: string): boolean {
|
||||
if (filePath) this.load(filePath);
|
||||
return this.proxies.length > 0;
|
||||
}
|
||||
|
||||
// ── Access ────────────────────────────────────────────────────────
|
||||
|
||||
/** Total number of proxies in the pool. */
|
||||
get size(): number {
|
||||
return this.proxies.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current proxy entry.
|
||||
* Returns `null` if pool is empty.
|
||||
*/
|
||||
getCurrent(): ProxyEntry | null {
|
||||
if (this.proxies.length === 0) return null;
|
||||
return this.proxies[this.currentIndex] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the proxy URL string for `fetch()`'s `proxy` option.
|
||||
*
|
||||
* Format: `http://username:password@host:port`
|
||||
* Returns `null` when the pool is empty.
|
||||
*/
|
||||
getProxyUrl(): string | null {
|
||||
const entry = this.getCurrent();
|
||||
if (!entry) return null;
|
||||
return this.formatProxyUrl(entry);
|
||||
}
|
||||
|
||||
/** Build a `http://user:pass@host:port` URL from an entry. */
|
||||
private formatProxyUrl(entry: ProxyEntry): string {
|
||||
const auth = entry.username
|
||||
? `${encodeURIComponent(entry.username)}:${encodeURIComponent(entry.password)}@`
|
||||
: "";
|
||||
return `http://${auth}${entry.host}:${entry.port}`;
|
||||
}
|
||||
|
||||
// ── Rotation ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Advance to the next proxy (round-robin, wraps around).
|
||||
* Returns the new current proxy or `null` if the pool is empty.
|
||||
*/
|
||||
rotate(): ProxyEntry | null {
|
||||
if (this.proxies.length === 0) return null;
|
||||
this.currentIndex = (this.currentIndex + 1) % this.proxies.length;
|
||||
return this.proxies[this.currentIndex] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the **current** proxy as failed.
|
||||
*
|
||||
* If the failure count exceeds `threshold`, the proxy is skipped and
|
||||
* rotation continues until a healthy proxy is found (or we've tried
|
||||
* all of them).
|
||||
*/
|
||||
markFailed(threshold?: number): ProxyEntry | null {
|
||||
const entry = this.getCurrent();
|
||||
if (!entry) return null;
|
||||
|
||||
const key = `${entry.host}:${entry.port}`;
|
||||
const count = (this.failures.get(key) ?? 0) + 1;
|
||||
this.failures.set(key, count);
|
||||
|
||||
const th = threshold ?? this.failureThreshold;
|
||||
if (count >= th) {
|
||||
console.warn(
|
||||
`[proxy-pool] Proxy ${key} failed ${count}/${th} times — skipping`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.rotate();
|
||||
}
|
||||
|
||||
/** Reset the failure counter for the current proxy. */
|
||||
markSuccess(): void {
|
||||
const entry = this.getCurrent();
|
||||
if (!entry) return;
|
||||
this.failures.delete(`${entry.host}:${entry.port}`);
|
||||
}
|
||||
|
||||
/** Set the failure count that triggers a permanent skip. */
|
||||
setFailureThreshold(n: number): void {
|
||||
this.failureThreshold = n;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user