Skip to content
Closed
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
20 changes: 17 additions & 3 deletions src/server/chat-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,15 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
return fail(400, error instanceof Error ? error.message : String(error), "invalid_request_error");
}

// Every recovery leg belongs to the same inbound request. Keep the transient send count
// outside `send` so 429 retries and key rotation cannot re-arm the configured total budget.
let transientSendsUsed = 0;
const remainingTransientSends = (): number | null => {
const policy = transientRetryPolicyFor(activeProvider);
return policy ? Math.max(0, policy.attempts - transientSendsUsed) : null;
};
const transientSendAvailable = (): boolean => remainingTransientSends() !== 0;

const send = async (request: AdapterRequest, recovery?: "rate-limit-429" | "key-429"): Promise<Response> => {
try {
// #2643: opted-in key-auth openai-chat providers retry pre-stream transient statuses on
Expand Down Expand Up @@ -232,7 +241,12 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
{
abortSignal: upstream.signal,
label: safeHostLabel(request.url),
...(transientPolicy ? { attempts: transientPolicy.attempts } : {}),
...(transientPolicy
? {
attempts: Math.max(1, transientPolicy.attempts - transientSendsUsed),
onSendsConsumed: (sends: number) => { transientSendsUsed += Math.max(0, sends); },
}
: {}),
},
);
} finally {
Expand All @@ -245,7 +259,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
response = await send(activeRequest);
const retryPolicy = rateLimitRetryPolicyFor(activeProvider);
let retries = 0;
while (response.status === 429 && retryPolicy && retries < retryPolicy.attempts) {
while (response.status === 429 && retryPolicy && retries < retryPolicy.attempts && transientSendAvailable()) {
retries += 1;
for await (const _ of prepareSameTarget429Wait({
body: response.body,
Expand All @@ -255,7 +269,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
if (upstream.signal.aborted) throw upstream.signal.reason;
response = await send(activeRequest, "rate-limit-429");
}
while (response.status === 429 && hasKeyPoolFailover(activeProvider)) {
while (response.status === 429 && hasKeyPoolFailover(activeProvider) && transientSendAvailable()) {
const rotated = rotateProviderTransportOn429(config, route.providerName, activeProvider, {
retryAfter: response.headers.get("retry-after"),
now: Date.now(),
Expand Down
13 changes: 13 additions & 0 deletions tests/transient-budget-scope-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ const source = (relative: string): string =>
* omission is visible.
*/
describe("transient send budget stays request-scoped", () => {
test("native chat recovery legs cannot re-arm the transient budget", () => {
const chat = source("server/chat-native.ts");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exercise the recovery budget through native chat

This test only searches the implementation text, so it can remain green even if the counter is wired at the wrong point or the recovery path exceeds the configured budget at runtime. Add a focused native-chat endpoint regression where the initial send returns 429 and the recovery leg returns persistent 503s, then assert that transientRetryOn5xx.attempts: 3 produces exactly three total upstream requests; behavior changes under src/ require focused regression coverage near the existing subsystem tests.

AGENTS.md reference: AGENTS.md:L336-L339

Useful? React with 👍 / 👎.


expect(chat.match(/let transientSendsUsed = 0;/g)).toHaveLength(1);
expect(chat).toContain("transientPolicy.attempts - transientSendsUsed");
expect(chat).toContain("onSendsConsumed: (sends: number) => { transientSendsUsed += Math.max(0, sends); }");

// Both the same-target 429 loop and key-pool failover must stop before dispatching a
// new leg once the one request-wide send allowance is exhausted.
expect(chat.match(/&& transientSendAvailable\(\)/g)).toHaveLength(2);
expect(chat).not.toContain("{ attempts: transientPolicy.attempts }");
});

test("every transient-retry call site draws from the shared counter", () => {
const core = source("server/responses/core.ts");

Expand Down
Loading