perf: stability and performance improvements across proxy

- Add exponential backoff (200ms-2s) between retry attempts
- Fix session retry bug: rotate proxy before retry to avoid same proxy
- Fix ACTIVE_READERS memory leak: release reader on stream done/error
- Make closeAllActiveReaders async with proper await
- WebSocket backpressure: pause upstream forwarding at 512KB buffer
- Optimize rate-limiter pruneTimestamps: avoid array reallocation
- Batch stream chunks (8 per yield) to reduce event loop overhead
- Add periodic cooldown cleanup in ProxyPool (30s interval)
- Extract shared wrapStreamWithCleanup utility to reduce duplication
- Fix fetchViaCurl temp file cleanup with proper unlink()
- Add missing ipv6Source overload to AI proxy handlers
This commit is contained in:
MythEclipse
2026-06-20 19:32:23 +07:00
parent c62de0899a
commit ac19f8f30d
6 changed files with 250 additions and 150 deletions
+43 -54
View File
@@ -12,9 +12,7 @@
import type { ProxyPool, SessionProxyPool } from "./proxy-pool";
import { MODEL_ROUTES, type BackendConfig } from "./ai-proxy";
import { fetchWithRetry, fetchWithSessionRetry, type FetchWithRetryResult } from "./fetch-utils";
import { SSELineBuffer } from "./fetch-utils";
import { isDevMode } from "./fetch-utils";
import { fetchWithRetry, fetchWithSessionRetry, SSELineBuffer, isDevMode, trackReader, releaseReader, wrapStreamWithCleanup, type FetchWithRetryResult } from "./fetch-utils";
// --- Types -------------------------------------------------------------------
@@ -277,7 +275,7 @@ function transformAnthropicStream(
model: string,
config: BackendConfig,
): ReadableStream {
const reader = body.getReader();
const reader = trackReader(body.getReader() as any as ReadableStreamDefaultReader);
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const lineBuffer = new SSELineBuffer();
@@ -340,33 +338,44 @@ function transformAnthropicStream(
}
while (phase === "block") {
const { done, value } = await reader.read();
if (done) {
stopKeepalive();
const remaining = lineBuffer.flush();
if (remaining.length > 0) {
const adapted = backendLineToAnthropicSSE(remaining, model, config);
// Process multiple chunks before yielding to event loop
// to reduce per-chunk setTimeout overhead (~1-4ms each)
const BATCH_SIZE = 8;
let chunksProcessed = 0;
while (phase === "block") {
const { done, value } = await reader.read();
if (done) {
stopKeepalive();
releaseReader(reader);
const remaining = lineBuffer.flush();
if (remaining.length > 0) {
const adapted = backendLineToAnthropicSSE(remaining, model, config);
if (adapted) {
controller.enqueue(encoder.encode(adapted + "\n\n"));
}
}
phase = "done";
break;
}
const chunk = decoder.decode(value, { stream: true });
const lines = lineBuffer.add(chunk);
for (const line of lines) {
const adapted = backendLineToAnthropicSSE(line, model, config);
if (adapted) {
controller.enqueue(encoder.encode(adapted + "\n\n"));
}
}
phase = "done";
break;
}
const chunk = decoder.decode(value, { stream: true });
const lines = lineBuffer.add(chunk);
for (const line of lines) {
const adapted = backendLineToAnthropicSSE(line, model, config);
if (adapted) {
controller.enqueue(encoder.encode(adapted + "\n\n"));
chunksProcessed++;
if (chunksProcessed >= BATCH_SIZE) {
chunksProcessed = 0;
await new Promise((r) => setTimeout(r, 0));
return; // Yield to event loop, pull will be called again
}
}
// Yield to event loop after each chunk
await new Promise((r) => setTimeout(r, 0));
return;
}
if (phase === "done") {
@@ -398,6 +407,7 @@ function transformAnthropicStream(
}
} catch (err) {
stopKeepalive();
releaseReader(reader);
if (isDevMode()) {
controller.enqueue(
encoder.encode(
@@ -500,6 +510,13 @@ export async function handleAnthropicMessages(
sessionPool?: SessionProxyPool,
sessionId?: string,
): Promise<Response>;
export async function handleAnthropicMessages(
body: unknown,
proxyPool?: ProxyPool,
sessionPool?: SessionProxyPool,
sessionId?: string,
ipv6Source?: string,
): Promise<Response>;
export async function handleAnthropicMessages(
body: unknown,
proxyPool?: ProxyPool,
@@ -650,6 +667,7 @@ export async function handleAnthropicMessages(
/**
* If a session is active, wrap the stream so the session is released on end/error.
* Otherwise pass through the stream unchanged.
* Uses the shared wrapStreamWithCleanup from fetch-utils.
*/
function wrapAnthropicStreamMaybe(
body: ReadableStream,
@@ -657,34 +675,5 @@ function wrapAnthropicStreamMaybe(
sessionId?: string,
): ReadableStream {
if (!sessionPool || !sessionId) return body;
return wrapAnthropicStreamWithCleanup(body, () => sessionPool.release(sessionId));
}
/**
* Wraps a ReadableStream and calls `cleanup` when the stream ends, errors,
* or is cancelled by the consumer.
*/
function wrapAnthropicStreamWithCleanup(body: ReadableStream, cleanup: () => void): ReadableStream {
const reader = body.getReader();
return new ReadableStream({
async pull(controller) {
try {
const { done, value } = await reader.read();
if (done) {
cleanup();
controller.close();
return;
}
controller.enqueue(value);
} catch (err) {
cleanup();
controller.error(err);
}
},
cancel(reason) {
cleanup();
reader.cancel(reason);
},
});
return wrapStreamWithCleanup(body, () => sessionPool.release(sessionId));
}