Skip to content

Commit 68e497c

Browse files
steebchenclaude
andcommitted
feat(gateway): same-key retry for single-provider routes
Retry once (or up to the configured budget) against the same env-var key when a model resolves to a single provider with a single env-var key — i.e. no alternate key and no other provider to fall back to. Covers both explicit provider/model requests and auto-routed requests that resolve to a single provider. When another provider exists, cross-provider fallback handles retries and same-key retry stays off. Bounded by the resolved routing-config retry.maxRetries budget (default 2 → up to 2 same-key retries; 0 disables). Auth failures (401/403) and BYOK/custom providers are excluded. The abort listener is re-armed before each same-key retry so a client disconnect during the retried upstream call still cancels. Key count is read from the resolved envVarName (which may be a regional override), not the provider default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 38447ef commit 68e497c

4 files changed

Lines changed: 422 additions & 6 deletions

File tree

apps/gateway/src/chat/chat.ts

Lines changed: 247 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ import { extractTokenUsage } from "./tools/extract-token-usage.js";
196196
import { extractToolCalls } from "./tools/extract-tool-calls.js";
197197
import { getFinishReasonFromError } from "./tools/get-finish-reason-from-error.js";
198198
import {
199+
getEnvKeyCount,
199200
getProviderEnv,
200201
getServiceTierIneligibleEnvIndices,
201202
hasServiceTierEligibleEnvCredential,
@@ -235,6 +236,7 @@ import {
235236
providerRetryKey,
236237
selectNextProvider,
237238
shouldRetryAlternateKey,
239+
shouldRetrySameKey,
238240
shouldRetryRequest,
239241
} from "./tools/retry-with-fallback.js";
240242
import {
@@ -6575,6 +6577,7 @@ chat.openapi(completions, async (c) => {
65756577
// --- Retry loop for provider fallback ---
65766578
const routingAttempts: RoutingAttempt[] = [];
65776579
const failedProviderIds = new Set<string>();
6580+
let sameKeyRetryCount = 0;
65786581
let res: Response | undefined;
65796582
for (
65806583
let retryAttempt = 0;
@@ -6775,8 +6778,23 @@ chat.openapi(completions, async (c) => {
67756778
maxRetries: routingCfg.retry.maxRetries,
67766779
});
67776780
const willRetrySameProvider = sameProviderRetryContext !== null;
6781+
const willRetrySameKey =
6782+
!willRetrySameProvider &&
6783+
!willRetryTimeout &&
6784+
shouldRetrySameKey({
6785+
usedProvider,
6786+
errorType: "upstream_timeout",
6787+
statusCode: 0,
6788+
envVarName,
6789+
envKeyCount: getEnvKeyCount(envVarName),
6790+
hasOtherProvider: (
6791+
routingMetadata?.providerScores ?? []
6792+
).some((s) => s.providerId !== usedProvider),
6793+
retryCount: sameKeyRetryCount,
6794+
maxRetries: routingCfg.retry.maxRetries,
6795+
});
67786796
const willRetryRequest =
6779-
willRetrySameProvider || willRetryTimeout;
6797+
willRetrySameProvider || willRetryTimeout || willRetrySameKey;
67806798

67816799
const baseLogEntry = createLogEntry(
67826800
requestId,
@@ -6874,6 +6892,30 @@ chat.openapi(completions, async (c) => {
68746892
continue;
68756893
}
68766894

6895+
if (willRetrySameKey) {
6896+
sameKeyRetryCount++;
6897+
// Re-add abort listener (removed by catch/finally on the
6898+
// failed attempt) so a client disconnect during the
6899+
// retried upstream call still cancels.
6900+
c.req.raw.signal.addEventListener("abort", onAbort);
6901+
routingAttempts.push(
6902+
buildRoutingAttempt(
6903+
usedProvider,
6904+
usedInternalModel,
6905+
0,
6906+
getErrorType(0),
6907+
false,
6908+
{
6909+
region: usedRegion,
6910+
apiKeyHash: usedApiKeyHash,
6911+
logId: attemptLogId,
6912+
},
6913+
),
6914+
);
6915+
retryAttempt--;
6916+
continue;
6917+
}
6918+
68776919
if (willRetryTimeout) {
68786920
routingAttempts.push(
68796921
buildRoutingAttempt(
@@ -7113,7 +7155,23 @@ chat.openapi(completions, async (c) => {
71137155
maxRetries: routingCfg.retry.maxRetries,
71147156
});
71157157
const willRetrySameProvider = sameProviderRetryContext !== null;
7116-
const willRetryRequest = willRetrySameProvider || willRetryFetch;
7158+
const willRetrySameKey =
7159+
!willRetrySameProvider &&
7160+
!willRetryFetch &&
7161+
shouldRetrySameKey({
7162+
usedProvider,
7163+
errorType: "network_error",
7164+
statusCode: 0,
7165+
envVarName,
7166+
envKeyCount: getEnvKeyCount(envVarName),
7167+
hasOtherProvider: (
7168+
routingMetadata?.providerScores ?? []
7169+
).some((s) => s.providerId !== usedProvider),
7170+
retryCount: sameKeyRetryCount,
7171+
maxRetries: routingCfg.retry.maxRetries,
7172+
});
7173+
const willRetryRequest =
7174+
willRetrySameProvider || willRetryFetch || willRetrySameKey;
71177175

71187176
const baseLogEntry = createLogEntry(
71197177
requestId,
@@ -7230,6 +7288,30 @@ chat.openapi(completions, async (c) => {
72307288
continue;
72317289
}
72327290

7291+
if (willRetrySameKey) {
7292+
sameKeyRetryCount++;
7293+
// Re-add abort listener (removed by catch/finally on the
7294+
// failed attempt) so a client disconnect during the
7295+
// retried upstream call still cancels.
7296+
c.req.raw.signal.addEventListener("abort", onAbort);
7297+
routingAttempts.push(
7298+
buildRoutingAttempt(
7299+
usedProvider,
7300+
usedInternalModel,
7301+
0,
7302+
getErrorType(0),
7303+
false,
7304+
{
7305+
region: usedRegion,
7306+
apiKeyHash: usedApiKeyHash,
7307+
logId: attemptLogId,
7308+
},
7309+
),
7310+
);
7311+
retryAttempt--;
7312+
continue;
7313+
}
7314+
72337315
if (willRetryFetch) {
72347316
routingAttempts.push(
72357317
buildRoutingAttempt(
@@ -7355,8 +7437,23 @@ chat.openapi(completions, async (c) => {
73557437
maxRetries: routingCfg.retry.maxRetries,
73567438
});
73577439
const willRetrySameProvider = sameProviderRetryContext !== null;
7440+
const willRetrySameKey =
7441+
!willRetrySameProvider &&
7442+
!willRetryHttpError &&
7443+
shouldRetrySameKey({
7444+
usedProvider,
7445+
errorType: finishReason,
7446+
statusCode: res.status,
7447+
envVarName,
7448+
envKeyCount: getEnvKeyCount(envVarName),
7449+
hasOtherProvider: (routingMetadata?.providerScores ?? []).some(
7450+
(s) => s.providerId !== usedProvider,
7451+
),
7452+
retryCount: sameKeyRetryCount,
7453+
maxRetries: routingCfg.retry.maxRetries,
7454+
});
73587455
const willRetryRequest =
7359-
willRetrySameProvider || willRetryHttpError;
7456+
willRetrySameProvider || willRetryHttpError || willRetrySameKey;
73607457

73617458
const baseLogEntry = createLogEntry(
73627459
requestId,
@@ -7514,6 +7611,30 @@ chat.openapi(completions, async (c) => {
75147611
continue;
75157612
}
75167613

7614+
if (willRetrySameKey) {
7615+
sameKeyRetryCount++;
7616+
// Re-add abort listener (removed by catch/finally on the
7617+
// failed attempt) so a client disconnect during the
7618+
// retried upstream call still cancels.
7619+
c.req.raw.signal.addEventListener("abort", onAbort);
7620+
routingAttempts.push(
7621+
buildRoutingAttempt(
7622+
usedProvider,
7623+
usedInternalModel,
7624+
res.status,
7625+
getErrorType(res.status),
7626+
false,
7627+
{
7628+
region: usedRegion,
7629+
apiKeyHash: usedApiKeyHash,
7630+
logId: attemptLogId,
7631+
},
7632+
),
7633+
);
7634+
retryAttempt--;
7635+
continue;
7636+
}
7637+
75177638
if (willRetryHttpError) {
75187639
routingAttempts.push(
75197640
buildRoutingAttempt(
@@ -7675,8 +7796,25 @@ chat.openapi(completions, async (c) => {
76757796
maxRetries: routingCfg.retry.maxRetries,
76767797
});
76777798
const willRetrySameProvider = sameProviderRetryContext !== null;
7799+
const willRetrySameKey =
7800+
!willRetrySameProvider &&
7801+
!willRetryStreamingError &&
7802+
shouldRetrySameKey({
7803+
usedProvider,
7804+
errorType,
7805+
statusCode: inferredStatusCode,
7806+
envVarName,
7807+
envKeyCount: getEnvKeyCount(envVarName),
7808+
hasOtherProvider: (routingMetadata?.providerScores ?? []).some(
7809+
(s) => s.providerId !== usedProvider,
7810+
),
7811+
retryCount: sameKeyRetryCount,
7812+
maxRetries: routingCfg.retry.maxRetries,
7813+
});
76787814
const willRetryRequest =
7679-
willRetrySameProvider || willRetryStreamingError;
7815+
willRetrySameProvider ||
7816+
willRetryStreamingError ||
7817+
willRetrySameKey;
76807818

76817819
const baseLogEntry = createLogEntry(
76827820
requestId,
@@ -7794,6 +7932,30 @@ chat.openapi(completions, async (c) => {
77947932
continue;
77957933
}
77967934

7935+
if (willRetrySameKey) {
7936+
sameKeyRetryCount++;
7937+
// Re-add abort listener (removed by catch/finally on the
7938+
// failed attempt) so a client disconnect during the
7939+
// retried upstream call still cancels.
7940+
c.req.raw.signal.addEventListener("abort", onAbort);
7941+
routingAttempts.push(
7942+
buildRoutingAttempt(
7943+
usedProvider,
7944+
usedInternalModel,
7945+
inferredStatusCode,
7946+
getErrorType(inferredStatusCode),
7947+
false,
7948+
{
7949+
region: usedRegion,
7950+
apiKeyHash: usedApiKeyHash,
7951+
logId: attemptLogId,
7952+
},
7953+
),
7954+
);
7955+
retryAttempt--;
7956+
continue;
7957+
}
7958+
77977959
if (willRetryStreamingError) {
77987960
routingAttempts.push(
77997961
buildRoutingAttempt(
@@ -10666,6 +10828,7 @@ chat.openapi(completions, async (c) => {
1066610828
// --- Retry loop for provider fallback ---
1066710829
const routingAttempts: RoutingAttempt[] = [];
1066810830
const failedProviderIds = new Set<string>();
10831+
let sameKeyRetryCount = 0;
1066910832
let canceled = false;
1067010833
let fetchError: Error | null = null;
1067110834
let isTimeoutFetchError = false;
@@ -10896,8 +11059,23 @@ chat.openapi(completions, async (c) => {
1089611059
maxRetries: routingCfg.retry.maxRetries,
1089711060
});
1089811061
const willRetrySameProvider = sameProviderRetryContext !== null;
11062+
const willRetrySameKey =
11063+
!willRetrySameProvider &&
11064+
!willRetryFetchNonStreaming &&
11065+
shouldRetrySameKey({
11066+
usedProvider,
11067+
errorType: "network_error",
11068+
statusCode: 0,
11069+
envVarName,
11070+
envKeyCount: getEnvKeyCount(envVarName),
11071+
hasOtherProvider: (routingMetadata?.providerScores ?? []).some(
11072+
(s) => s.providerId !== usedProvider,
11073+
),
11074+
retryCount: sameKeyRetryCount,
11075+
maxRetries: routingCfg.retry.maxRetries,
11076+
});
1089911077
const willRetryRequest =
10900-
willRetrySameProvider || willRetryFetchNonStreaming;
11078+
willRetrySameProvider || willRetryFetchNonStreaming || willRetrySameKey;
1090111079

1090211080
const baseLogEntry = createLogEntry(
1090311081
requestId,
@@ -11015,6 +11193,30 @@ chat.openapi(completions, async (c) => {
1101511193
continue;
1101611194
}
1101711195

11196+
if (willRetrySameKey) {
11197+
sameKeyRetryCount++;
11198+
// Re-add abort listener (removed by the per-attempt finally) so
11199+
// a client disconnect during the retried upstream call still
11200+
// cancels.
11201+
c.req.raw.signal.addEventListener("abort", onAbort);
11202+
routingAttempts.push(
11203+
buildRoutingAttempt(
11204+
usedProvider,
11205+
usedInternalModel,
11206+
0,
11207+
getErrorType(0),
11208+
false,
11209+
{
11210+
region: usedRegion,
11211+
apiKeyHash: usedApiKeyHash,
11212+
logId: attemptLogId,
11213+
},
11214+
),
11215+
);
11216+
retryAttempt--;
11217+
continue;
11218+
}
11219+
1101811220
if (willRetryFetchNonStreaming) {
1101911221
routingAttempts.push(
1102011222
buildRoutingAttempt(
@@ -11278,8 +11480,23 @@ chat.openapi(completions, async (c) => {
1127811480
maxRetries: routingCfg.retry.maxRetries,
1127911481
});
1128011482
const willRetrySameProvider = sameProviderRetryContext !== null;
11483+
const willRetrySameKey =
11484+
!willRetrySameProvider &&
11485+
!willRetryHttpNonStreaming &&
11486+
shouldRetrySameKey({
11487+
usedProvider,
11488+
errorType: finishReason,
11489+
statusCode: res.status,
11490+
envVarName,
11491+
envKeyCount: getEnvKeyCount(envVarName),
11492+
hasOtherProvider: (routingMetadata?.providerScores ?? []).some(
11493+
(s) => s.providerId !== usedProvider,
11494+
),
11495+
retryCount: sameKeyRetryCount,
11496+
maxRetries: routingCfg.retry.maxRetries,
11497+
});
1128111498
const willRetryRequest =
11282-
willRetrySameProvider || willRetryHttpNonStreaming;
11499+
willRetrySameProvider || willRetryHttpNonStreaming || willRetrySameKey;
1128311500

1128411501
const baseLogEntry = createLogEntry(
1128511502
requestId,
@@ -11456,6 +11673,30 @@ chat.openapi(completions, async (c) => {
1145611673
continue;
1145711674
}
1145811675

11676+
if (willRetrySameKey) {
11677+
sameKeyRetryCount++;
11678+
// Re-add abort listener (removed by the per-attempt finally) so
11679+
// a client disconnect during the retried upstream call still
11680+
// cancels.
11681+
c.req.raw.signal.addEventListener("abort", onAbort);
11682+
routingAttempts.push(
11683+
buildRoutingAttempt(
11684+
usedProvider,
11685+
usedInternalModel,
11686+
res.status,
11687+
getErrorType(res.status),
11688+
false,
11689+
{
11690+
region: usedRegion,
11691+
apiKeyHash: usedApiKeyHash,
11692+
logId: attemptLogId,
11693+
},
11694+
),
11695+
);
11696+
retryAttempt--;
11697+
continue;
11698+
}
11699+
1145911700
if (willRetryHttpNonStreaming) {
1146011701
routingAttempts.push(
1146111702
buildRoutingAttempt(

apps/gateway/src/chat/tools/get-provider-env.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { HTTPException } from "hono/http-exception";
22

33
import {
44
getRoundRobinValue,
5+
parseCommaSeparatedEnv,
56
peekRoundRobinValue,
67
} from "@/lib/round-robin-env.js";
78

@@ -147,3 +148,20 @@ export function getProviderEnv(
147148

148149
return { token: result.value, configIndex: result.index, envVarName: envVar };
149150
}
151+
152+
/**
153+
* Returns the number of comma-separated values configured in the named env
154+
* var, or 0 if it's unset/empty. Pass the resolved `envVarName` from the
155+
* provider context — it may be a regional override (e.g. `*__SINGAPORE`)
156+
* rather than the provider's base var.
157+
*/
158+
export function getEnvKeyCount(envVarName: string | undefined): number {
159+
if (!envVarName) {
160+
return 0;
161+
}
162+
const value = process.env[envVarName];
163+
if (!value) {
164+
return 0;
165+
}
166+
return parseCommaSeparatedEnv(value).length;
167+
}

0 commit comments

Comments
 (0)