Skip to content

Commit b2aa55f

Browse files
committed
fix: check remapped errorResponse status for 404->429 usage_limit rotation
When OpenAI returns 404 with usage_limit_reached body, fetch-helpers.ts remaps it to a 429 response. But the main pipeline was checking the original response.status (404) instead of errorResponse.status (429), so the rate-limit handling branch never ran. This caused exhausted accounts to appear healthy and sessions to be killed instead of rotating to available accounts. Change index.ts:1861 from: if (response.status === 429) to: if (errorResponse.status === 429 && rateLimit) The added rateLimit guard also prevents entitlement-style 429s (where rateLimit is explicitly set to undefined) from incorrectly marking accounts as rate-limited. Tests: 2 new tests covering both scenarios, full suite passes (3314 tests).
1 parent 76e809f commit b2aa55f

2 files changed

Lines changed: 90 additions & 8 deletions

File tree

index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1866,7 +1866,7 @@ let sessionAffinityWriteVersion = 0;
18661866
break;
18671867
}
18681868

1869-
if (response.status === 429) {
1869+
if (errorResponse.status === 429 && rateLimit) {
18701870
runtimeMetrics.rateLimitedResponses++;
18711871
const retryAfterMs =
18721872
rateLimit?.retryAfterMs ?? 60_000;

test/index.test.ts

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4260,10 +4260,9 @@ describe("OpenAIOAuthPlugin runtime toast forwarding", () => {
42604260
expect(showRuntimeToastMock).not.toHaveBeenCalled();
42614261
});
42624262

4263-
it("applies the default cooldown when a 429 has no parsed retry metadata", async () => {
4263+
it("skips rate-limit marking when a 429 has no parsed retry metadata (entitlement-style)", async () => {
42644264
const { AccountManager } = await import("../lib/accounts.js");
42654265
const fetchHelpersModule = await import("../lib/request/fetch-helpers.js");
4266-
const rateLimitBackoffModule = await import("../lib/request/rate-limit-backoff.js");
42674266

42684267
const markRateLimitedWithReason = vi.fn();
42694268
const manager = {
@@ -4318,13 +4317,95 @@ describe("OpenAIOAuthPlugin runtime toast forwarding", () => {
43184317
rateLimit: undefined,
43194318
errorBody: "rate limited",
43204319
} as never);
4320+
globalThis.fetch = vi
4321+
.fn()
4322+
.mockResolvedValueOnce(new Response("rate limited", { status: 429 }));
4323+
4324+
const mockClient = createMockClient();
4325+
const { OpenAIOAuthPlugin } = await import("../index.js");
4326+
const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType;
4327+
const sdk = await plugin.auth.loader(getOAuthAuth, { options: {}, models: {} });
4328+
const response = await sdk.fetch!("https://api.openai.com/v1/chat/completions", {
4329+
method: "POST",
4330+
body: JSON.stringify({ model: "gpt-5.1" }),
4331+
});
4332+
4333+
expect(response.status).toBe(429);
4334+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
4335+
expect(markRateLimitedWithReason).not.toHaveBeenCalled();
4336+
});
4337+
4338+
it("rotates account when upstream 404 usage_limit_reached is remapped to 429", async () => {
4339+
const { AccountManager } = await import("../lib/accounts.js");
4340+
const fetchHelpersModule = await import("../lib/request/fetch-helpers.js");
4341+
const rateLimitBackoffModule = await import("../lib/request/rate-limit-backoff.js");
4342+
4343+
const markRateLimitedWithReason = vi.fn();
4344+
const manager = {
4345+
getAccountCount: () => 1,
4346+
getCurrentOrNextForFamilyHybrid: () => ({
4347+
index: 0,
4348+
accountId: "acc-1",
4349+
email: "alpha@example.com",
4350+
refreshToken: "refresh-1",
4351+
}),
4352+
getCurrentOrNextForFamily: () => ({
4353+
index: 0,
4354+
accountId: "acc-1",
4355+
email: "alpha@example.com",
4356+
refreshToken: "refresh-1",
4357+
}),
4358+
getCurrentWorkspace: () => null,
4359+
getAccountByIndex: () => null,
4360+
getAccountsSnapshot: () => [],
4361+
isAccountAvailableForFamily: () => true,
4362+
toAuthDetails: () => ({
4363+
type: "oauth" as const,
4364+
access: "access-token",
4365+
refresh: "refresh-1",
4366+
expires: Date.now() + 60_000,
4367+
}),
4368+
hasRefreshToken: () => true,
4369+
saveToDiskDebounced: () => {},
4370+
updateFromAuth: () => {},
4371+
clearAuthFailures: () => {},
4372+
incrementAuthFailures: () => 1,
4373+
saveToDisk: async () => {},
4374+
markAccountCoolingDown: () => {},
4375+
markRateLimited: () => {},
4376+
markRateLimitedWithReason,
4377+
consumeToken: () => true,
4378+
refundToken: () => {},
4379+
syncCodexCliActiveSelectionForIndex: async () => {},
4380+
markSwitched: () => {},
4381+
removeAccount: () => {},
4382+
recordFailure: () => {},
4383+
recordSuccess: () => {},
4384+
recordRateLimit: () => {},
4385+
getMinWaitTimeForFamily: () => 0,
4386+
shouldShowAccountToast: () => true,
4387+
markToastShown: () => {},
4388+
setActiveIndex: () => null,
4389+
};
4390+
vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(manager as never);
4391+
vi.mocked(fetchHelpersModule.handleErrorResponse).mockResolvedValueOnce({
4392+
response: new Response(JSON.stringify({ error: { code: "usage_limit_reached", message: "Usage limit reached" } }), { status: 429 }),
4393+
rateLimit: {
4394+
retryAfterMs: 2 * 60 * 60 * 1000,
4395+
code: "usage_limit_reached",
4396+
},
4397+
errorBody: JSON.stringify({ error: { code: "usage_limit_reached", message: "Usage limit reached" } }),
4398+
} as never);
43214399
vi.mocked(rateLimitBackoffModule.getRateLimitBackoff).mockReturnValueOnce({
4322-
attempt: 1,
4323-
delayMs: 5_000,
4400+
attempt: 2,
4401+
delayMs: 1000,
43244402
});
43254403
globalThis.fetch = vi
43264404
.fn()
4327-
.mockResolvedValueOnce(new Response("rate limited", { status: 429 }));
4405+
.mockResolvedValueOnce(
4406+
new Response(JSON.stringify({ error: { code: "usage_limit_reached", message: "Usage limit reached" } }), { status: 404 }),
4407+
)
4408+
.mockResolvedValueOnce(new Response(JSON.stringify({ content: "ok" }), { status: 200 }));
43284409

43294410
const mockClient = createMockClient();
43304411
const { OpenAIOAuthPlugin } = await import("../index.js");
@@ -4336,11 +4417,12 @@ describe("OpenAIOAuthPlugin runtime toast forwarding", () => {
43364417
});
43374418

43384419
expect(response.status).toBe(503);
4420+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
43394421
expect(markRateLimitedWithReason).toHaveBeenCalledWith(
43404422
expect.objectContaining({ index: 0 }),
4341-
60_000,
4423+
2 * 60 * 60 * 1000,
43424424
"gpt-5.1",
4343-
"unknown",
4425+
expect.any(String),
43444426
"gpt-5.1",
43454427
);
43464428
});

0 commit comments

Comments
 (0)