Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ const providerSchema = z.object({
modelLimits: modelLimitsSchema,
concurrentLimit: z.number().int().min(1).optional(),
poolSize: z.number().int().min(1).max(100).optional(),
connectionRetries: z.number().int().min(0).max(10).optional(),
circuitBreaker: z.object({
failureThreshold: z.number().int().min(1).optional(),
windowSeconds: z.number().int().min(1).optional(),
Expand Down Expand Up @@ -222,15 +223,15 @@ export function findConfigFile(cwd: string = process.cwd(), { skipGlobal = false
* Used by init wizard to show existing providers and offer add/edit. */
export function peekConfig(
cwd?: string,
): { configPath: string; providers: Map<string, { baseUrl: string; envKey: string; authType: "anthropic" | "bearer"; timeout: number; ttfbTimeout?: number; concurrentLimit?: number; stallTimeout?: number; poolSize?: number; circuitBreaker?: { threshold?: number; windowSeconds?: number; cooldown?: number } }>; server: { port: number; host: string } | null; modelRouting: Map<string, { provider: string; model: string; weight?: number }[]>; hedging?: { speculativeDelay: number; cvThreshold: number; maxHedge: number } } | null {
): { configPath: string; providers: Map<string, { baseUrl: string; envKey: string; authType: "anthropic" | "bearer"; timeout: number; ttfbTimeout?: number; concurrentLimit?: number; stallTimeout?: number; poolSize?: number; connectionRetries?: number; circuitBreaker?: { threshold?: number; windowSeconds?: number; cooldown?: number } }>; server: { port: number; host: string } | null; modelRouting: Map<string, { provider: string; model: string; weight?: number }[]>; hedging?: { speculativeDelay: number; cvThreshold: number; maxHedge: number } } | null {
const configPath = findConfigFile(cwd);
if (!configPath) return null;

const raw = readFileSync(configPath, "utf-8");
const parsed = parseYaml(raw) as Record<string, unknown>;
const providersRaw = (parsed?.providers ?? {}) as Record<string, Record<string, unknown>>;

const providers = new Map<string, { baseUrl: string; envKey: string; authType: "anthropic" | "bearer"; timeout: number; ttfbTimeout?: number; concurrentLimit?: number; stallTimeout?: number; poolSize?: number; circuitBreaker?: { threshold?: number; windowSeconds?: number; cooldown?: number } }>();
const providers = new Map<string, { baseUrl: string; envKey: string; authType: "anthropic" | "bearer"; timeout: number; ttfbTimeout?: number; concurrentLimit?: number; stallTimeout?: number; poolSize?: number; connectionRetries?: number; circuitBreaker?: { threshold?: number; windowSeconds?: number; cooldown?: number } }>();

for (const [id, config] of Object.entries(providersRaw)) {
const apiKey = String(config.apiKey ?? "");
Expand All @@ -254,6 +255,7 @@ export function peekConfig(
concurrentLimit: config.concurrentLimit !== undefined ? Number(config.concurrentLimit) : undefined,
stallTimeout: config.stallTimeout !== undefined ? Number(config.stallTimeout) : undefined,
poolSize: config.poolSize !== undefined ? Number(config.poolSize) : undefined,
connectionRetries: config.connectionRetries !== undefined ? Number(config.connectionRetries) : undefined,
circuitBreaker,
});
}
Expand Down Expand Up @@ -442,6 +444,7 @@ export async function loadConfig(configPath?: string, cwd?: string): Promise<{ c
});
createdAgents.push(providerConfig._agent);
providerConfig.poolSize = poolSize ?? 10;
providerConfig._connectionRetries = p.connectionRetries;
// Create per-provider circuit breaker
const cbConfig = p.circuitBreaker;
providerConfig._circuitBreaker = new CircuitBreaker(cbConfig ? {
Expand Down
7 changes: 4 additions & 3 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -997,7 +997,8 @@ async function forwardWithRetry(
): Promise<Response> {
let lastResult: Response | undefined;

for (let attempt = 0; attempt <= CONNECTION_RETRY_MAX; attempt++) {
const maxRetries = provider._connectionRetries ?? CONNECTION_RETRY_MAX;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const result = await forwardRequest(provider, entry, ctx, incomingRequest, chainSignal, index);

// Non-502 responses pass through immediately (success or upstream error)
Expand Down Expand Up @@ -1035,7 +1036,7 @@ async function forwardWithRetry(
}

const delay = CONNECTION_RETRY_BASE_MS * Math.pow(2, attempt);
console.warn(`[proxy] Connection error on "${provider.name}" (attempt ${attempt + 1}/${CONNECTION_RETRY_MAX}), retrying in ${delay}ms: ${body.slice(0, 200)}`);
console.warn(`[proxy] Connection error on "${provider.name}" (attempt ${attempt + 1}/${maxRetries}), retrying in ${delay}ms: ${body.slice(0, 200)}`);

// Reset stream state for retry
ctx._streamState = "start";
Expand All @@ -1056,7 +1057,7 @@ async function forwardWithRetry(
}
// Stall errors are recorded in handleStall() (per-request, no retry amplification).

console.warn(`[proxy] All ${CONNECTION_RETRY_MAX + 1} attempts failed for "${provider.name}" — escalating to fallback`);
console.warn(`[proxy] All ${maxRetries + 1} attempts failed for "${provider.name}" — escalating to fallback`);
return lastResult!;
}

Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface ProviderConfig {
_circuitBreaker?: CircuitBreaker;
_serverConfig?: ServerConfig;
poolSize?: number;
/** Max connection-level retries (TTFB timeout/stall/connection failure) before escalating to fallback. Default: 3 */
_connectionRetries?: number;
}

export interface RoutingEntry {
Expand Down
Loading