feat(proxy): improve channel failover stability and sticky session coordination / 优化代理通道故障切换与粘滞会话协调 (#268)
* wip: preserve local changes before upstream merge * Improve proxy routing stability and add stable-first strategy * Sync explicit-group routing strategy to eligible source routes * Fix proxy retry and runtime health review issues * fix: make proxy channel retry attempts configurable * fix: harden proxy retry recovery and bookkeeping * fix: address pr 257 review comments * feat(proxy): add sticky session channel coordination --------- Co-authored-by: Cita <juricek.chen@gmail.com>
This commit is contained in:
@@ -43,6 +43,11 @@ import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/con
|
||||
import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||
import { canRetryProxyChannel, getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import {
|
||||
acquireSurfaceChannelLease,
|
||||
bindSurfaceStickyChannel,
|
||||
buildSurfaceChannelBusyMessage,
|
||||
buildSurfaceStickySessionKey,
|
||||
clearSurfaceStickyChannel,
|
||||
createSurfaceFailureToolkit,
|
||||
createSurfaceDispatchRequest,
|
||||
recordSurfaceSuccess,
|
||||
@@ -126,6 +131,12 @@ export async function handleChatSurfaceRequest(
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
});
|
||||
const stickySessionKey = buildSurfaceStickySessionKey({
|
||||
clientContext,
|
||||
requestedModel,
|
||||
downstreamPath,
|
||||
downstreamApiKeyId,
|
||||
});
|
||||
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
@@ -136,6 +147,7 @@ export async function handleChatSurfaceRequest(
|
||||
downstreamPolicy,
|
||||
excludeChannelIds,
|
||||
retryCount,
|
||||
stickySessionKey,
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
@@ -250,6 +262,36 @@ export async function handleChatSurfaceRequest(
|
||||
return endpointStrategy.tryRecover(ctx);
|
||||
};
|
||||
let startTime = Date.now();
|
||||
const leaseResult = await acquireSurfaceChannelLease({
|
||||
selected,
|
||||
});
|
||||
if (leaseResult.status === 'timeout') {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
const busyMessage = buildSurfaceChannelBusyMessage(leaseResult.waitMs);
|
||||
await failureToolkit.log({
|
||||
selected,
|
||||
modelRequested: requestedModel,
|
||||
status: 'failed',
|
||||
httpStatus: 503,
|
||||
latencyMs: leaseResult.waitMs,
|
||||
errorMessage: busyMessage,
|
||||
retryCount,
|
||||
});
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
return reply.code(503).send({
|
||||
error: {
|
||||
message: busyMessage,
|
||||
type: 'server_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
const channelLease = leaseResult.lease;
|
||||
|
||||
try {
|
||||
const endpointResult = await executeEndpointFlow({
|
||||
@@ -287,6 +329,10 @@ export async function handleChatSurfaceRequest(
|
||||
});
|
||||
|
||||
if (!endpointResult.ok) {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
const failureOutcome = await failureToolkit.handleUpstreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
@@ -358,6 +404,10 @@ export async function handleChatSurfaceRequest(
|
||||
);
|
||||
const latency = Date.now() - startTime;
|
||||
if (streamResult.status === 'failed') {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
await failureToolkit.recordStreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
@@ -372,6 +422,10 @@ export async function handleChatSurfaceRequest(
|
||||
});
|
||||
return;
|
||||
}
|
||||
bindSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
return;
|
||||
}
|
||||
let fallbackData: unknown = null;
|
||||
@@ -387,6 +441,10 @@ export async function handleChatSurfaceRequest(
|
||||
const latency = Date.now() - startTime;
|
||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||
if (failure) {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
const failureOutcome = await failureToolkit.handleDetectedFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
@@ -409,6 +467,10 @@ export async function handleChatSurfaceRequest(
|
||||
startSseResponse();
|
||||
const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
|
||||
if (streamResult.status === 'failed') {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
await failureToolkit.recordStreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
@@ -424,6 +486,10 @@ export async function handleChatSurfaceRequest(
|
||||
});
|
||||
return;
|
||||
}
|
||||
bindSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
} else {
|
||||
startSseResponse();
|
||||
const upstreamReader = upstream.body?.getReader();
|
||||
@@ -453,6 +519,10 @@ export async function handleChatSurfaceRequest(
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
if (streamResult.status === 'failed') {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
await failureToolkit.recordStreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
@@ -493,6 +563,10 @@ export async function handleChatSurfaceRequest(
|
||||
errorLabel: '[proxy/chat] failed to record success metrics',
|
||||
},
|
||||
});
|
||||
bindSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -524,6 +598,10 @@ export async function handleChatSurfaceRequest(
|
||||
const parsedUsage = parseProxyUsage(upstreamData);
|
||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||
if (failure) {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
const failureOutcome = await failureToolkit.handleDetectedFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
@@ -562,12 +640,20 @@ export async function handleChatSurfaceRequest(
|
||||
errorLabel: '[proxy/chat] failed to record success metrics',
|
||||
},
|
||||
});
|
||||
bindSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
|
||||
return reply.send(downstreamResponse);
|
||||
} catch (err: any) {
|
||||
const failureOutcome = await failureToolkit.handleExecutionError({
|
||||
selected,
|
||||
requestedModel,
|
||||
} catch (err: any) {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
const failureOutcome = await failureToolkit.handleExecutionError({
|
||||
selected,
|
||||
requestedModel,
|
||||
modelName,
|
||||
errorMessage: err?.message || 'network failure',
|
||||
latencyMs: Date.now() - startTime,
|
||||
@@ -576,10 +662,12 @@ export async function handleChatSurfaceRequest(
|
||||
if (failureOutcome.action === 'retry') {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
} finally {
|
||||
channelLease.release();
|
||||
}
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deriveCodexSessionCacheKey(input: {
|
||||
@@ -646,6 +734,12 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
});
|
||||
const stickySessionKey = buildSurfaceStickySessionKey({
|
||||
clientContext,
|
||||
requestedModel,
|
||||
downstreamPath,
|
||||
downstreamApiKeyId,
|
||||
});
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
@@ -655,6 +749,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
downstreamPolicy,
|
||||
excludeChannelIds,
|
||||
retryCount,
|
||||
stickySessionKey,
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
@@ -692,6 +787,36 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
}
|
||||
const oauth = getOauthInfoFromAccount(selected.account);
|
||||
const startTime = Date.now();
|
||||
const leaseResult = await acquireSurfaceChannelLease({
|
||||
selected,
|
||||
});
|
||||
if (leaseResult.status === 'timeout') {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
const busyMessage = buildSurfaceChannelBusyMessage(leaseResult.waitMs);
|
||||
await failureToolkit.log({
|
||||
selected,
|
||||
modelRequested: requestedModel,
|
||||
status: 'failed',
|
||||
httpStatus: 503,
|
||||
latencyMs: leaseResult.waitMs,
|
||||
errorMessage: busyMessage,
|
||||
retryCount,
|
||||
});
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
return reply.code(503).send({
|
||||
error: {
|
||||
message: busyMessage,
|
||||
type: 'server_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
const channelLease = leaseResult.lease;
|
||||
|
||||
const buildRequest = () => {
|
||||
const upstreamRequest = buildClaudeCountTokensUpstreamRequest({
|
||||
@@ -753,6 +878,10 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
const failureOutcome = await failureToolkit.handleUpstreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
@@ -782,8 +911,16 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
retryCount,
|
||||
upstreamPath: upstreamRequest.path,
|
||||
});
|
||||
bindSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
return reply.code(upstream.status).type(contentType).send(payload);
|
||||
} catch (error: any) {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
const failureOutcome = await failureToolkit.handleExecutionError({
|
||||
selected,
|
||||
requestedModel,
|
||||
@@ -797,6 +934,8 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
continue;
|
||||
}
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
} finally {
|
||||
channelLease.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,11 @@ import {
|
||||
import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import {
|
||||
acquireSurfaceChannelLease,
|
||||
bindSurfaceStickyChannel,
|
||||
buildSurfaceChannelBusyMessage,
|
||||
buildSurfaceStickySessionKey,
|
||||
clearSurfaceStickyChannel,
|
||||
createSurfaceFailureToolkit,
|
||||
createSurfaceDispatchRequest,
|
||||
recordSurfaceSuccess,
|
||||
@@ -178,23 +183,30 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const maxRetries = getProxyMaxChannelRetries();
|
||||
const failureToolkit = createSurfaceFailureToolkit({
|
||||
warningScope: 'responses',
|
||||
downstreamPath,
|
||||
maxRetries,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
});
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
const failureToolkit = createSurfaceFailureToolkit({
|
||||
warningScope: 'responses',
|
||||
downstreamPath,
|
||||
maxRetries,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
});
|
||||
const stickySessionKey = buildSurfaceStickySessionKey({
|
||||
clientContext,
|
||||
requestedModel,
|
||||
downstreamPath,
|
||||
downstreamApiKeyId,
|
||||
});
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount <= maxRetries) {
|
||||
const selected = await selectSurfaceChannelForAttempt({
|
||||
requestedModel,
|
||||
downstreamPolicy,
|
||||
excludeChannelIds,
|
||||
retryCount,
|
||||
});
|
||||
const selected = await selectSurfaceChannelForAttempt({
|
||||
requestedModel,
|
||||
downstreamPolicy,
|
||||
excludeChannelIds,
|
||||
retryCount,
|
||||
stickySessionKey,
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
await reportProxyAllFailed({
|
||||
@@ -336,9 +348,39 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
return endpointStrategy.tryRecover(ctx);
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
const startTime = Date.now();
|
||||
const leaseResult = await acquireSurfaceChannelLease({
|
||||
selected,
|
||||
});
|
||||
if (leaseResult.status === 'timeout') {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
const busyMessage = buildSurfaceChannelBusyMessage(leaseResult.waitMs);
|
||||
await failureToolkit.log({
|
||||
selected,
|
||||
modelRequested: requestedModel,
|
||||
status: 'failed',
|
||||
httpStatus: 503,
|
||||
latencyMs: leaseResult.waitMs,
|
||||
errorMessage: busyMessage,
|
||||
retryCount,
|
||||
});
|
||||
retryCount += 1;
|
||||
if (retryCount <= maxRetries) {
|
||||
continue;
|
||||
}
|
||||
return reply.code(503).send({
|
||||
error: {
|
||||
message: busyMessage,
|
||||
type: 'server_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
const channelLease = leaseResult.lease;
|
||||
|
||||
try {
|
||||
try {
|
||||
const endpointResult = await executeEndpointFlow({
|
||||
siteUrl: selected.site.url,
|
||||
endpointCandidates,
|
||||
@@ -373,10 +415,14 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
},
|
||||
});
|
||||
|
||||
if (!endpointResult.ok) {
|
||||
const failureOutcome = await failureToolkit.handleUpstreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
if (!endpointResult.ok) {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
const failureOutcome = await failureToolkit.handleUpstreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
modelName,
|
||||
status: endpointResult.status || 502,
|
||||
errText: endpointResult.errText || 'unknown error',
|
||||
@@ -465,10 +511,14 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
reply.raw,
|
||||
);
|
||||
const latency = Date.now() - startTime;
|
||||
if (streamResult.status === 'failed') {
|
||||
await failureToolkit.recordStreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
if (streamResult.status === 'failed') {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
await failureToolkit.recordStreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
modelName,
|
||||
errorMessage: streamResult.errorMessage,
|
||||
latencyMs: latency,
|
||||
@@ -479,11 +529,15 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await finalizeStreamSuccess(parsedUsage, latency);
|
||||
return;
|
||||
}
|
||||
await finalizeStreamSuccess(parsedUsage, latency);
|
||||
bindSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
return;
|
||||
}
|
||||
let upstreamData: unknown = rawText;
|
||||
try {
|
||||
upstreamData = JSON.parse(rawText);
|
||||
@@ -497,10 +551,14 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
parsedUsage = parseProxyUsage(upstreamData);
|
||||
const latency = Date.now() - startTime;
|
||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||
if (failure) {
|
||||
const failureOutcome = await failureToolkit.handleDetectedFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
if (failure) {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
const failureOutcome = await failureToolkit.handleDetectedFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
modelName,
|
||||
failure,
|
||||
latencyMs: latency,
|
||||
@@ -519,10 +577,14 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
|
||||
startSseResponse();
|
||||
const streamResult = streamSession.consumeUpstreamFinalPayload(upstreamData, rawText, reply.raw);
|
||||
if (streamResult.status === 'failed') {
|
||||
await failureToolkit.recordStreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
if (streamResult.status === 'failed') {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
await failureToolkit.recordStreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
modelName,
|
||||
errorMessage: streamResult.errorMessage,
|
||||
latencyMs: latency,
|
||||
@@ -534,11 +596,15 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
runtimeFailureStatus: 502,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await finalizeStreamSuccess(parsedUsage, latency);
|
||||
return;
|
||||
}
|
||||
await finalizeStreamSuccess(parsedUsage, latency);
|
||||
bindSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
startSseResponse();
|
||||
|
||||
@@ -569,10 +635,14 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
rawText += decoder.decode();
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
if (streamResult.status === 'failed') {
|
||||
await failureToolkit.recordStreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
if (streamResult.status === 'failed') {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
await failureToolkit.recordStreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
modelName,
|
||||
errorMessage: streamResult.errorMessage,
|
||||
latencyMs: latency,
|
||||
@@ -589,11 +659,15 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
// Once SSE has been hijacked and bytes may already be on the wire, we
|
||||
// must not attempt to convert stream failures into a fresh HTTP error
|
||||
// response or retry on another channel. Responses stream failures are
|
||||
// handled in-band by the proxy stream session.
|
||||
// handled in-band by the proxy stream session.
|
||||
|
||||
await finalizeStreamSuccess(parsedUsage, latency);
|
||||
return;
|
||||
}
|
||||
await finalizeStreamSuccess(parsedUsage, latency);
|
||||
bindSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
|
||||
let rawText = '';
|
||||
@@ -627,10 +701,14 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
const latency = Date.now() - startTime;
|
||||
const parsedUsage = parseProxyUsage(upstreamData);
|
||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||
if (failure) {
|
||||
const failureOutcome = await failureToolkit.handleDetectedFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
if (failure) {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
const failureOutcome = await failureToolkit.handleDetectedFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
modelName,
|
||||
failure,
|
||||
latencyMs: latency,
|
||||
@@ -675,14 +753,22 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
errorLabel: '[responses] post-response bookkeeping failed:',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[responses] post-response success logging failed:', error);
|
||||
}
|
||||
return reply.send(downstreamData);
|
||||
} catch (err: any) {
|
||||
const failureOutcome = await failureToolkit.handleExecutionError({
|
||||
selected,
|
||||
requestedModel,
|
||||
} catch (error) {
|
||||
console.error('[responses] post-response success logging failed:', error);
|
||||
}
|
||||
bindSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
return reply.send(downstreamData);
|
||||
} catch (err: any) {
|
||||
clearSurfaceStickyChannel({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
const failureOutcome = await failureToolkit.handleExecutionError({
|
||||
selected,
|
||||
requestedModel,
|
||||
modelName,
|
||||
errorMessage: err?.message || 'network failure',
|
||||
latencyMs: Date.now() - startTime,
|
||||
@@ -691,8 +777,10 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
if (failureOutcome.action === 'retry') {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
} finally {
|
||||
channelLease.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EMPTY_DOWNSTREAM_ROUTING_POLICY } from '../../services/downstreamPolicy
|
||||
|
||||
const selectChannelMock = vi.fn();
|
||||
const selectNextChannelMock = vi.fn();
|
||||
const selectPreferredChannelMock = vi.fn();
|
||||
const recordFailureMock = vi.fn();
|
||||
const refreshModelsAndRebuildRoutesMock = vi.fn();
|
||||
const composeProxyLogMessageMock = vi.fn();
|
||||
@@ -20,6 +21,11 @@ const recordSuccessMock = vi.fn();
|
||||
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn();
|
||||
const resolveProxyLogBillingMock = vi.fn();
|
||||
const refreshOauthAccessTokenSingleflightMock = vi.fn();
|
||||
const getStickyChannelIdMock = vi.fn();
|
||||
const bindStickyChannelMock = vi.fn();
|
||||
const clearStickyChannelMock = vi.fn();
|
||||
const acquireChannelLeaseMock = vi.fn();
|
||||
const buildStickySessionKeyMock = vi.fn();
|
||||
const consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
@@ -27,11 +33,22 @@ vi.mock('../../services/tokenRouter.js', () => ({
|
||||
tokenRouter: {
|
||||
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
||||
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
|
||||
selectPreferredChannel: (...args: unknown[]) => selectPreferredChannelMock(...args),
|
||||
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
|
||||
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/proxyChannelCoordinator.js', () => ({
|
||||
proxyChannelCoordinator: {
|
||||
getStickyChannelId: (...args: unknown[]) => getStickyChannelIdMock(...args),
|
||||
bindStickyChannel: (...args: unknown[]) => bindStickyChannelMock(...args),
|
||||
clearStickyChannel: (...args: unknown[]) => clearStickyChannelMock(...args),
|
||||
acquireChannelLease: (...args: unknown[]) => acquireChannelLeaseMock(...args),
|
||||
buildStickySessionKey: (...args: unknown[]) => buildStickySessionKeyMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/modelService.js', () => ({
|
||||
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
|
||||
}));
|
||||
@@ -95,6 +112,7 @@ describe('selectSurfaceChannelForAttempt', () => {
|
||||
beforeEach(() => {
|
||||
selectChannelMock.mockReset();
|
||||
selectNextChannelMock.mockReset();
|
||||
selectPreferredChannelMock.mockReset();
|
||||
recordFailureMock.mockReset();
|
||||
refreshModelsAndRebuildRoutesMock.mockReset();
|
||||
composeProxyLogMessageMock.mockReset();
|
||||
@@ -112,6 +130,11 @@ describe('selectSurfaceChannelForAttempt', () => {
|
||||
resolveProxyUsageWithSelfLogFallbackMock.mockReset();
|
||||
resolveProxyLogBillingMock.mockReset();
|
||||
refreshOauthAccessTokenSingleflightMock.mockReset();
|
||||
getStickyChannelIdMock.mockReset();
|
||||
bindStickyChannelMock.mockReset();
|
||||
clearStickyChannelMock.mockReset();
|
||||
acquireChannelLeaseMock.mockReset();
|
||||
buildStickySessionKeyMock.mockReset();
|
||||
consoleWarnMock.mockClear();
|
||||
consoleErrorMock.mockClear();
|
||||
});
|
||||
@@ -158,6 +181,51 @@ describe('selectSurfaceChannelForAttempt', () => {
|
||||
expect(refreshModelsAndRebuildRoutesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prefers the sticky session channel on the first attempt when it is still eligible', async () => {
|
||||
const selected = { channel: { id: 55 } };
|
||||
getStickyChannelIdMock.mockReturnValueOnce(55);
|
||||
selectPreferredChannelMock.mockResolvedValueOnce(selected);
|
||||
|
||||
const { selectSurfaceChannelForAttempt } = await import('./sharedSurface.js');
|
||||
const result = await selectSurfaceChannelForAttempt({
|
||||
requestedModel: 'gpt-5.2',
|
||||
downstreamPolicy: EMPTY_DOWNSTREAM_ROUTING_POLICY,
|
||||
excludeChannelIds: [],
|
||||
retryCount: 0,
|
||||
stickySessionKey: 'sticky-session',
|
||||
});
|
||||
|
||||
expect(result).toBe(selected);
|
||||
expect(selectPreferredChannelMock).toHaveBeenCalledWith(
|
||||
'gpt-5.2',
|
||||
55,
|
||||
EMPTY_DOWNSTREAM_ROUTING_POLICY,
|
||||
[],
|
||||
);
|
||||
expect(selectChannelMock).not.toHaveBeenCalled();
|
||||
expect(clearStickyChannelMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears stale sticky bindings and falls back to regular selection when the preferred channel is unavailable', async () => {
|
||||
const selected = { channel: { id: 22 } };
|
||||
getStickyChannelIdMock.mockReturnValueOnce(55);
|
||||
selectPreferredChannelMock.mockResolvedValueOnce(null);
|
||||
selectChannelMock.mockResolvedValueOnce(selected);
|
||||
|
||||
const { selectSurfaceChannelForAttempt } = await import('./sharedSurface.js');
|
||||
const result = await selectSurfaceChannelForAttempt({
|
||||
requestedModel: 'gpt-5.2',
|
||||
downstreamPolicy: EMPTY_DOWNSTREAM_ROUTING_POLICY,
|
||||
excludeChannelIds: [],
|
||||
retryCount: 0,
|
||||
stickySessionKey: 'sticky-session',
|
||||
});
|
||||
|
||||
expect(result).toBe(selected);
|
||||
expect(clearStickyChannelMock).toHaveBeenCalledWith('sticky-session', 55);
|
||||
expect(selectChannelMock).toHaveBeenCalledWith('gpt-5.2', EMPTY_DOWNSTREAM_ROUTING_POLICY);
|
||||
});
|
||||
|
||||
it('logs refresh failures and still retries selection once on the first attempt', async () => {
|
||||
const selected = { channel: { id: 33 } };
|
||||
selectChannelMock
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
||||
import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js';
|
||||
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
|
||||
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
||||
import { proxyChannelCoordinator } from '../../services/proxyChannelCoordinator.js';
|
||||
|
||||
type SelectedChannel = Awaited<ReturnType<typeof tokenRouter.selectChannel>>;
|
||||
type SurfaceWarningScope = 'chat' | 'responses';
|
||||
@@ -103,14 +104,34 @@ export async function selectSurfaceChannelForAttempt(input: {
|
||||
downstreamPolicy: DownstreamRoutingPolicy;
|
||||
excludeChannelIds: number[];
|
||||
retryCount: number;
|
||||
stickySessionKey?: string | null;
|
||||
}): Promise<SelectedChannel> {
|
||||
let selected = input.retryCount === 0
|
||||
? await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(
|
||||
input.requestedModel,
|
||||
input.excludeChannelIds,
|
||||
input.downstreamPolicy,
|
||||
);
|
||||
let selected: SelectedChannel = null;
|
||||
|
||||
if (input.retryCount === 0 && input.stickySessionKey) {
|
||||
const preferredChannelId = proxyChannelCoordinator.getStickyChannelId(input.stickySessionKey);
|
||||
if (preferredChannelId && !input.excludeChannelIds.includes(preferredChannelId)) {
|
||||
selected = await tokenRouter.selectPreferredChannel(
|
||||
input.requestedModel,
|
||||
preferredChannelId,
|
||||
input.downstreamPolicy,
|
||||
input.excludeChannelIds,
|
||||
);
|
||||
if (!selected) {
|
||||
proxyChannelCoordinator.clearStickyChannel(input.stickySessionKey, preferredChannelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
selected = input.retryCount === 0
|
||||
? await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(
|
||||
input.requestedModel,
|
||||
input.excludeChannelIds,
|
||||
input.downstreamPolicy,
|
||||
);
|
||||
}
|
||||
|
||||
if (!selected && input.retryCount === 0) {
|
||||
try {
|
||||
@@ -124,6 +145,65 @@ export async function selectSurfaceChannelForAttempt(input: {
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function buildSurfaceStickySessionKey(input: {
|
||||
clientContext?: DownstreamClientContext | null;
|
||||
requestedModel: string;
|
||||
downstreamPath: string;
|
||||
downstreamApiKeyId?: number | null;
|
||||
}): string | null {
|
||||
return proxyChannelCoordinator.buildStickySessionKey({
|
||||
clientKind: input.clientContext?.clientKind || null,
|
||||
sessionId: input.clientContext?.sessionId || null,
|
||||
requestedModel: input.requestedModel,
|
||||
downstreamPath: input.downstreamPath,
|
||||
downstreamApiKeyId: input.downstreamApiKeyId,
|
||||
});
|
||||
}
|
||||
|
||||
export function bindSurfaceStickyChannel(input: {
|
||||
stickySessionKey?: string | null;
|
||||
selected: {
|
||||
channel: { id: number };
|
||||
account?: { extraConfig?: string | null } | null;
|
||||
};
|
||||
}): void {
|
||||
proxyChannelCoordinator.bindStickyChannel(
|
||||
input.stickySessionKey,
|
||||
input.selected.channel.id,
|
||||
input.selected.account?.extraConfig,
|
||||
);
|
||||
}
|
||||
|
||||
export function clearSurfaceStickyChannel(input: {
|
||||
stickySessionKey?: string | null;
|
||||
selected: {
|
||||
channel: { id: number };
|
||||
};
|
||||
}): void {
|
||||
proxyChannelCoordinator.clearStickyChannel(
|
||||
input.stickySessionKey,
|
||||
input.selected.channel.id,
|
||||
);
|
||||
}
|
||||
|
||||
export async function acquireSurfaceChannelLease(input: {
|
||||
selected: {
|
||||
channel: { id: number };
|
||||
account?: { extraConfig?: string | null } | null;
|
||||
};
|
||||
}) {
|
||||
return await proxyChannelCoordinator.acquireChannelLease({
|
||||
channelId: input.selected.channel.id,
|
||||
accountExtraConfig: input.selected.account?.extraConfig,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSurfaceChannelBusyMessage(waitMs: number): string {
|
||||
return waitMs > 0
|
||||
? `Channel busy: waited ${waitMs}ms for an available session slot`
|
||||
: 'Channel busy: no session slot available';
|
||||
}
|
||||
|
||||
export async function writeSurfaceProxyLog(input: {
|
||||
warningScope: string;
|
||||
selected: {
|
||||
|
||||
Reference in New Issue
Block a user