feat: add structured logging across proxy pool and fetch utils

- fetch-utils.ts: logProxy() — logs every attempt, proxy used, success/failure
  with sessionId prefix for traceability
- proxy-pool.ts: logPool() — logs acquire/release/rotate/markFailed/markSuccess
  with active session count and proxy host info
- Both use consistent [prefix] HH:MM:SS.mmm key=value format

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-17 04:29:31 +07:00
co-authored by Claude
parent 519fe3af83
commit 45f1b33fb6
2 changed files with 95 additions and 9 deletions
+52 -1
View File
@@ -74,6 +74,21 @@ export class SSELineBuffer {
} }
} }
// ─── Structured logging helper ─────────────────────────────────────────
const LOG_PREFIX = "[fetch-utils]";
function logProxy(method: string, msg: string, extra?: Record<string, unknown>): void {
const ts = new Date().toISOString().slice(11, 23);
const parts = [`${LOG_PREFIX} ${ts}`, `method=${method}`, msg];
if (extra) {
for (const [k, v] of Object.entries(extra)) {
parts.push(`${k}=${v ?? "null"}`);
}
}
console.log(parts.join(" "));
}
// ─── Error sanitization (prevent leaking upstream details) ────────────── // ─── Error sanitization (prevent leaking upstream details) ──────────────
/** /**
@@ -140,17 +155,22 @@ export async function fetchWithRetry(
usedProxy = false; usedProxy = false;
} }
const proxyShort = init.proxy ? init.proxy.replace(/https?:\/\//, "").replace(/@.*/, "@***") : "direct";
logProxy("fetchWithRetry", `attempt=${attempt + 1}/3 proxy=${proxyShort}`, { context });
try { try {
response = await fetch(url, init); response = await fetch(url, init);
if (response.ok) { if (response.ok) {
if (usedProxy && proxyPool && proxyPool.size > 0) { if (usedProxy && proxyPool && proxyPool.size > 0) {
proxyPool.markSuccess(); proxyPool.markSuccess();
} }
logProxy("fetchWithRetry", `success attempt=${attempt + 1} status=${response.status}`, { context });
return { response }; return { response };
} }
// Non-2xx — mark proxy as failed and retry // Non-2xx — mark proxy as failed and retry
lastError = new Error(`Upstream returned ${response.status}`); lastError = new Error(`Upstream returned ${response.status}`);
logProxy("fetchWithRetry", `non-2xx attempt=${attempt + 1} status=${response.status}`, { context });
if (usedProxy && proxyPool && proxyPool.size > 0 && init.proxy) { if (usedProxy && proxyPool && proxyPool.size > 0 && init.proxy) {
proxyPool.markFailed(); proxyPool.markFailed();
usedProxy = false; usedProxy = false;
@@ -165,6 +185,7 @@ export async function fetchWithRetry(
// Log the actual error so operators can diagnose // Log the actual error so operators can diagnose
const ctx = context ? `[${context}] ` : ""; const ctx = context ? `[${context}] ` : "";
const errMsg = err instanceof Error ? err.message : String(err); const errMsg = err instanceof Error ? err.message : String(err);
logProxy("fetchWithRetry", `failed attempt=${attempt + 1} err=${errMsg}`, { context });
console.warn(`${ctx}fetch attempt ${attempt + 1}/3 failed: ${errMsg}`); console.warn(`${ctx}fetch attempt ${attempt + 1}/3 failed: ${errMsg}`);
} }
} }
@@ -172,10 +193,12 @@ export async function fetchWithRetry(
// All attempts exhausted — classify the last error // All attempts exhausted — classify the last error
if (!response) { if (!response) {
const err = lastError ?? new Error("All connection attempts failed"); const err = lastError ?? new Error("All connection attempts failed");
logProxy("fetchWithRetry", "exhausted all retries", { context });
return { errorClassification: classifyFetchErrorSafe(err) }; return { errorClassification: classifyFetchErrorSafe(err) };
} }
// Non-2xx but we have a response — pass it along (caller handles it) // Non-2xx but we have a response — pass it along (caller handles it)
logProxy("fetchWithRetry", `returning non-2xx response status=${response.status}`, { context });
return { response }; return { response };
} }
@@ -237,6 +260,7 @@ export async function fetchWithSessionRetry(
): Promise<FetchWithRetryResult> { ): Promise<FetchWithRetryResult> {
// Fallback when no session pool is available // Fallback when no session pool is available
if (!sessionPool) { if (!sessionPool) {
logProxy("fetchWithSessionRetry", "no session pool — direct fetch", { context, sessionId: sessionId.slice(0, 8) });
try { try {
const response = await fetch(url, init); const response = await fetch(url, init);
return { response }; return { response };
@@ -258,17 +282,35 @@ export async function fetchWithSessionRetry(
init.proxy = proxyUrl; init.proxy = proxyUrl;
} }
const proxyShort = init.proxy
? init.proxy.replace(/https?:\/\//, "").replace(/@.*/, "@***")
: "direct";
logProxy("fetchWithSessionRetry", `attempt=${attempt + 1}/${maxRetries} proxy=${proxyShort}`, {
context,
sessionId: sessionId.slice(0, 8),
});
try { try {
const response = await fetch(url, init); const response = await fetch(url, init);
if (response.ok) { if (response.ok) {
sessionPool.markSuccess(sessionId); sessionPool.markSuccess(sessionId);
logProxy("fetchWithSessionRetry", `success attempt=${attempt + 1} status=${response.status}`, {
context,
sessionId: sessionId.slice(0, 8),
proxy: proxyShort,
});
return { response }; return { response };
} }
// Non-2xx — mark session failed and retry // Non-2xx — mark session failed and retry
lastError = new Error(`Upstream returned ${response.status}`); lastError = new Error(`Upstream returned ${response.status}`);
const rotated = sessionPool.markFailed(sessionId); const rotated = sessionPool.markFailed(sessionId);
logProxy("fetchWithSessionRetry", `non-2xx attempt=${attempt + 1} status=${response.status} rotated=${rotated}`, {
context,
sessionId: sessionId.slice(0, 8),
proxy: proxyShort,
});
const ctx = context ? `[${context}] ` : ""; const ctx = context ? `[${context}] ` : "";
console.warn( console.warn(
@@ -278,9 +320,14 @@ export async function fetchWithSessionRetry(
} catch (err) { } catch (err) {
lastError = err; lastError = err;
const rotated = sessionPool.markFailed(sessionId); const rotated = sessionPool.markFailed(sessionId);
const errMsg = err instanceof Error ? err.message : String(err);
logProxy("fetchWithSessionRetry", `failed attempt=${attempt + 1} err=${errMsg} rotated=${rotated}`, {
context,
sessionId: sessionId.slice(0, 8),
proxy: proxyShort,
});
const ctx = context ? `[${context}] ` : ""; const ctx = context ? `[${context}] ` : "";
const errMsg = err instanceof Error ? err.message : String(err);
console.warn( console.warn(
`${ctx}fetchWithSessionRetry attempt ${attempt + 1}/${maxRetries} ` + `${ctx}fetchWithSessionRetry attempt ${attempt + 1}/${maxRetries} ` +
`failed: ${errMsg}${rotated ? " (rotated proxy)" : ""}`, `failed: ${errMsg}${rotated ? " (rotated proxy)" : ""}`,
@@ -289,6 +336,10 @@ export async function fetchWithSessionRetry(
} }
// All attempts exhausted — release session and classify last error // All attempts exhausted — release session and classify last error
logProxy("fetchWithSessionRetry", "exhausted all retries — releasing session", {
context,
sessionId: sessionId.slice(0, 8),
});
sessionPool.release(sessionId); sessionPool.release(sessionId);
const err = lastError ?? new Error("All session proxy attempts failed"); const err = lastError ?? new Error("All session proxy attempts failed");
return { errorClassification: classifyFetchErrorSafe(err) }; return { errorClassification: classifyFetchErrorSafe(err) };
+43 -8
View File
@@ -19,6 +19,19 @@ export interface ProxyEntry {
// --- ProxyPool --------------------------------------------------------------- // --- ProxyPool ---------------------------------------------------------------
const POOL_PREFIX = "[proxy-pool]";
function logPool(msg: string, extra?: Record<string, unknown>): void {
const ts = new Date().toISOString().slice(11, 23);
const parts = [`${POOL_PREFIX} ${ts}`, msg];
if (extra) {
for (const [k, v] of Object.entries(extra)) {
parts.push(`${k}=${v ?? "null"}`);
}
}
console.log(parts.join(" "));
}
export class ProxyPool { export class ProxyPool {
private proxies: ProxyEntry[] = []; private proxies: ProxyEntry[] = [];
private currentIndex = 0; private currentIndex = 0;
@@ -71,11 +84,7 @@ export class ProxyPool {
this.currentIndex = 0; this.currentIndex = 0;
this.failures.clear(); this.failures.clear();
if (this.proxies.length > 0) { logPool(`loaded ${this.proxies.length} proxies from ${filePath}`);
console.log(
`[proxy-pool] Loaded ${this.proxies.length} proxies from ${filePath}`,
);
}
} }
/** /**
@@ -112,7 +121,9 @@ export class ProxyPool {
getProxyUrl(): string | null { getProxyUrl(): string | null {
const entry = this.getCurrent(); const entry = this.getCurrent();
if (!entry) return null; if (!entry) return null;
return this.formatProxyUrl(entry); const url = this.formatProxyUrl(entry);
logPool(`getProxyUrl -> ${entry.host}:${entry.port}`, { index: this.currentIndex });
return url;
} }
/** Build a `http://user:pass@host:port` URL from an entry. */ /** Build a `http://user:pass@host:port` URL from an entry. */
@@ -131,8 +142,11 @@ export class ProxyPool {
*/ */
rotate(): ProxyEntry | null { rotate(): ProxyEntry | null {
if (this.proxies.length === 0) return null; if (this.proxies.length === 0) return null;
const oldIndex = this.currentIndex;
this.currentIndex = (this.currentIndex + 1) % this.proxies.length; this.currentIndex = (this.currentIndex + 1) % this.proxies.length;
return this.proxies[this.currentIndex] ?? null; const entry = this.proxies[this.currentIndex] ?? null;
logPool(`rotate ${oldIndex} -> ${this.currentIndex}`, { host: entry?.host });
return entry;
} }
/** /**
@@ -152,6 +166,7 @@ export class ProxyPool {
this.failures.set(key, count); this.failures.set(key, count);
const th = threshold ?? this.failureThreshold; const th = threshold ?? this.failureThreshold;
logPool(`markFailed ${key} (${count}/${th})`);
if (count >= th) { if (count >= th) {
console.warn( console.warn(
`[proxy-pool] Proxy ${key} failed ${count}/${th} times -- skipping`, `[proxy-pool] Proxy ${key} failed ${count}/${th} times -- skipping`,
@@ -164,7 +179,9 @@ export class ProxyPool {
const entry = this.getCurrent(); const entry = this.getCurrent();
if (!entry) return true; if (!entry) return true;
const key = `${entry.host}:${entry.port}`; const key = `${entry.host}:${entry.port}`;
return (this.failures.get(key) ?? 0) >= (threshold ?? this.failureThreshold); const failed = (this.failures.get(key) ?? 0) >= (threshold ?? this.failureThreshold);
if (failed) logPool(`isFailed true for ${key}`);
return failed;
} }
/** Reset the failure counter for the current proxy. */ /** Reset the failure counter for the current proxy. */
@@ -172,6 +189,7 @@ export class ProxyPool {
const entry = this.getCurrent(); const entry = this.getCurrent();
if (!entry) return; if (!entry) return;
this.failures.delete(`${entry.host}:${entry.port}`); this.failures.delete(`${entry.host}:${entry.port}`);
logPool(`markSuccess ${entry.host}:${entry.port}`);
} }
/** Set the failure count that triggers a permanent skip. */ /** Set the failure count that triggers a permanent skip. */
@@ -215,6 +233,7 @@ export class SessionProxyPool {
if (poolOrPath) this.pool.load(poolOrPath); if (poolOrPath) this.pool.load(poolOrPath);
} }
this.failureThreshold = 3; this.failureThreshold = 3;
logPool(`SessionProxyPool created, poolSize=${this.pool.size}`);
} }
/** Number of available proxies in the underlying pool. */ /** Number of available proxies in the underlying pool. */
@@ -240,6 +259,7 @@ export class SessionProxyPool {
const existing = this.sessions.get(sessionId); const existing = this.sessions.get(sessionId);
if (existing !== undefined) { if (existing !== undefined) {
logPool(`acquire existing session=${sessionId.slice(0, 8)} proxyIndex=${existing.proxyIndex}`);
return this.formatProxyUrlAtIndex(existing.proxyIndex); return this.formatProxyUrlAtIndex(existing.proxyIndex);
} }
@@ -255,6 +275,8 @@ export class SessionProxyPool {
} }
usedBy.add(sessionId); usedBy.add(sessionId);
const entry = this.poolEntryAtIndex(index);
logPool(`acquire session=${sessionId.slice(0, 8)} -> proxyIndex=${index} host=${entry?.host} activeSessions=${this.activeSessions}`);
return this.formatProxyUrlAtIndex(index); return this.formatProxyUrlAtIndex(index);
} }
@@ -262,6 +284,8 @@ export class SessionProxyPool {
getProxyUrl(sessionId: string): string | null { getProxyUrl(sessionId: string): string | null {
const info = this.sessions.get(sessionId); const info = this.sessions.get(sessionId);
if (!info) return null; if (!info) return null;
const entry = this.poolEntryAtIndex(info.proxyIndex);
logPool(`getProxyUrl session=${sessionId.slice(0, 8)} proxyIndex=${info.proxyIndex} host=${entry?.host}`);
return this.formatProxyUrlAtIndex(info.proxyIndex); return this.formatProxyUrlAtIndex(info.proxyIndex);
} }
@@ -277,6 +301,8 @@ export class SessionProxyPool {
} }
this.sessions.delete(sessionId); this.sessions.delete(sessionId);
const entry = this.poolEntryAtIndex(info.proxyIndex);
logPool(`release session=${sessionId.slice(0, 8)} proxyIndex=${info.proxyIndex} host=${entry?.host} activeSessions=${this.activeSessions}`);
} }
/** /**
@@ -292,9 +318,12 @@ export class SessionProxyPool {
if (!info) return false; if (!info) return false;
info.failures += 1; info.failures += 1;
logPool(`markFailed session=${sessionId.slice(0, 8)} proxyIndex=${info.proxyIndex} failures=${info.failures}/${this.failureThreshold}`);
if (info.failures < this.failureThreshold) return false; if (info.failures < this.failureThreshold) return false;
const oldIndex = info.proxyIndex; const oldIndex = info.proxyIndex;
const oldEntry = this.poolEntryAtIndex(oldIndex);
// Mark in underlying pool (public API only works on currentIndex) // Mark in underlying pool (public API only works on currentIndex)
const savedIdx = (this.pool as any).currentIndex as number; const savedIdx = (this.pool as any).currentIndex as number;
@@ -314,6 +343,7 @@ export class SessionProxyPool {
if (newIndex === -1 || newIndex === oldIndex) { if (newIndex === -1 || newIndex === oldIndex) {
// Single-proxy pool or none available — reset failures, stay put // Single-proxy pool or none available — reset failures, stay put
this.sessions.set(sessionId, { proxyIndex: oldIndex, failures: 0 }); this.sessions.set(sessionId, { proxyIndex: oldIndex, failures: 0 });
logPool(`markFailed no alternative, staying on oldIndex=${oldIndex} host=${oldEntry?.host}`);
return false; return false;
} }
@@ -326,6 +356,8 @@ export class SessionProxyPool {
} }
newUsedBy.add(sessionId); newUsedBy.add(sessionId);
const newEntry = this.poolEntryAtIndex(newIndex);
logPool(`markFailed rotated session=${sessionId.slice(0, 8)} ${oldEntry?.host} -> ${newEntry?.host}`);
return true; return true;
} }
@@ -334,6 +366,7 @@ export class SessionProxyPool {
const info = this.sessions.get(sessionId); const info = this.sessions.get(sessionId);
if (!info) return; if (!info) return;
info.failures = 0; info.failures = 0;
logPool(`markSuccess session=${sessionId.slice(0, 8)} proxyIndex=${info.proxyIndex}`);
} }
// -- Internals ---------------------------------------------------------------- // -- Internals ----------------------------------------------------------------
@@ -362,12 +395,14 @@ export class SessionProxyPool {
for (let i = 0; i < this.pool.size; i++) { for (let i = 0; i < this.pool.size; i++) {
const count = this.proxyUsage.get(i)?.size ?? 0; const count = this.proxyUsage.get(i)?.size ?? 0;
logPool(`pickLeastUsed proxy[${i}] count=${count}`);
if (count < bestCount) { if (count < bestCount) {
bestCount = count; bestCount = count;
bestIndex = i; bestIndex = i;
} }
} }
logPool(`pickLeastUsed selected index=${bestIndex} count=${bestCount}`);
return bestIndex; return bestIndex;
} }