Skip to content

Commit c6d0b5e

Browse files
committed
test: add 4 broader scenarios for 404->429 rotation path
1 parent b2aa55f commit c6d0b5e

1 file changed

Lines changed: 265 additions & 0 deletions

File tree

test/index.test.ts

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4427,6 +4427,271 @@ describe("OpenAIOAuthPlugin runtime toast forwarding", () => {
44274427
);
44284428
});
44294429

4430+
it("retries same account on short-cooldown 429 without marking rate-limited", async () => {
4431+
const { AccountManager } = await import("../lib/accounts.js");
4432+
const fetchHelpersModule = await import("../lib/request/fetch-helpers.js");
4433+
const rateLimitBackoffModule = await import("../lib/request/rate-limit-backoff.js");
4434+
4435+
const markRateLimitedWithReason = vi.fn();
4436+
const manager = {
4437+
getAccountCount: () => 1,
4438+
getCurrentOrNextForFamilyHybrid: () => ({ index: 0, accountId: "acc-1", email: "alpha@example.com", refreshToken: "refresh-1" }),
4439+
getCurrentOrNextForFamily: () => ({ index: 0, accountId: "acc-1", email: "alpha@example.com", refreshToken: "refresh-1" }),
4440+
getCurrentWorkspace: () => null,
4441+
getAccountByIndex: () => null,
4442+
getAccountsSnapshot: () => [],
4443+
isAccountAvailableForFamily: () => true,
4444+
toAuthDetails: () => ({ type: "oauth" as const, access: "access-token", refresh: "refresh-1", expires: Date.now() + 60_000 }),
4445+
hasRefreshToken: () => true,
4446+
saveToDiskDebounced: () => {},
4447+
updateFromAuth: () => {},
4448+
clearAuthFailures: () => {},
4449+
incrementAuthFailures: () => 1,
4450+
saveToDisk: async () => {},
4451+
markAccountCoolingDown: () => {},
4452+
markRateLimited: () => {},
4453+
markRateLimitedWithReason,
4454+
consumeToken: () => true,
4455+
refundToken: () => {},
4456+
syncCodexCliActiveSelectionForIndex: async () => {},
4457+
markSwitched: () => {},
4458+
removeAccount: () => {},
4459+
recordFailure: () => {},
4460+
recordSuccess: () => {},
4461+
recordRateLimit: () => {},
4462+
getMinWaitTimeForFamily: () => 0,
4463+
shouldShowAccountToast: () => false,
4464+
markToastShown: () => {},
4465+
setActiveIndex: () => null,
4466+
};
4467+
vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(manager as never);
4468+
// Short cooldown: 1000ms < 5000ms threshold -> retries same account, does NOT rotate
4469+
vi.mocked(fetchHelpersModule.handleErrorResponse).mockResolvedValueOnce({
4470+
response: new Response("rate limited", { status: 429 }),
4471+
rateLimit: { retryAfterMs: 1000, code: "rate_limit_exceeded" },
4472+
errorBody: "rate limited",
4473+
} as never);
4474+
vi.mocked(rateLimitBackoffModule.getRateLimitBackoff).mockReturnValueOnce({ attempt: 1, delayMs: 500 });
4475+
globalThis.fetch = vi
4476+
.fn()
4477+
.mockResolvedValueOnce(new Response("rate limited", { status: 429 }))
4478+
.mockResolvedValueOnce(new Response(JSON.stringify({ content: "ok" }), { status: 200 }));
4479+
4480+
const mockClient = createMockClient();
4481+
const { OpenAIOAuthPlugin } = await import("../index.js");
4482+
const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType;
4483+
const sdk = await plugin.auth.loader(getOAuthAuth, { options: {}, models: {} });
4484+
const response = await sdk.fetch!("https://api.openai.com/v1/chat/completions", {
4485+
method: "POST",
4486+
body: JSON.stringify({ model: "gpt-5.1" }),
4487+
});
4488+
4489+
// Short cooldown -> retries same account -> gets 200 on second attempt
4490+
expect(response.status).toBe(200);
4491+
// markRateLimitedWithReason is for long cooldowns; short retries skip it
4492+
expect(markRateLimitedWithReason).not.toHaveBeenCalled();
4493+
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
4494+
});
4495+
4496+
it("does not rotate on 404 with unrelated body (not a usage limit)", async () => {
4497+
const { AccountManager } = await import("../lib/accounts.js");
4498+
const fetchHelpersModule = await import("../lib/request/fetch-helpers.js");
4499+
4500+
const markRateLimitedWithReason = vi.fn();
4501+
const manager = {
4502+
getAccountCount: () => 1,
4503+
getCurrentOrNextForFamilyHybrid: () => ({ index: 0, accountId: "acc-1", email: "alpha@example.com", refreshToken: "refresh-1" }),
4504+
getCurrentOrNextForFamily: () => ({ index: 0, accountId: "acc-1", email: "alpha@example.com", refreshToken: "refresh-1" }),
4505+
getCurrentWorkspace: () => null,
4506+
getAccountByIndex: () => null,
4507+
getAccountsSnapshot: () => [],
4508+
isAccountAvailableForFamily: () => true,
4509+
toAuthDetails: () => ({ type: "oauth" as const, access: "access-token", refresh: "refresh-1", expires: Date.now() + 60_000 }),
4510+
hasRefreshToken: () => true,
4511+
saveToDiskDebounced: () => {},
4512+
updateFromAuth: () => {},
4513+
clearAuthFailures: () => {},
4514+
incrementAuthFailures: () => 1,
4515+
saveToDisk: async () => {},
4516+
markAccountCoolingDown: () => {},
4517+
markRateLimited: () => {},
4518+
markRateLimitedWithReason,
4519+
consumeToken: () => true,
4520+
refundToken: () => {},
4521+
syncCodexCliActiveSelectionForIndex: async () => {},
4522+
markSwitched: () => {},
4523+
removeAccount: () => {},
4524+
recordFailure: () => {},
4525+
recordSuccess: () => {},
4526+
recordRateLimit: () => {},
4527+
getMinWaitTimeForFamily: () => 0,
4528+
shouldShowAccountToast: () => false,
4529+
markToastShown: () => {},
4530+
setActiveIndex: () => null,
4531+
};
4532+
vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(manager as never);
4533+
// 404 with unrelated body: handleErrorResponse returns 404 + no rateLimit
4534+
vi.mocked(fetchHelpersModule.handleErrorResponse).mockResolvedValueOnce({
4535+
response: new Response(JSON.stringify({ error: { code: "model_not_found" } }), { status: 404 }),
4536+
rateLimit: undefined,
4537+
errorBody: JSON.stringify({ error: { code: "model_not_found" } }),
4538+
} as never);
4539+
globalThis.fetch = vi
4540+
.fn()
4541+
.mockResolvedValueOnce(new Response(JSON.stringify({ error: { code: "model_not_found" } }), { status: 404 }));
4542+
4543+
const mockClient = createMockClient();
4544+
const { OpenAIOAuthPlugin } = await import("../index.js");
4545+
const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType;
4546+
const sdk = await plugin.auth.loader(getOAuthAuth, { options: {}, models: {} });
4547+
const response = await sdk.fetch!("https://api.openai.com/v1/chat/completions", {
4548+
method: "POST",
4549+
body: JSON.stringify({ model: "gpt-5.1" }),
4550+
});
4551+
4552+
// Falls through as a plain 404; no rate-limit rotation
4553+
expect(response.status).toBe(404);
4554+
expect(markRateLimitedWithReason).not.toHaveBeenCalled();
4555+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
4556+
});
4557+
4558+
it("does not mark rate-limited when entitlement 404 is remapped to 403", async () => {
4559+
const { AccountManager } = await import("../lib/accounts.js");
4560+
const fetchHelpersModule = await import("../lib/request/fetch-helpers.js");
4561+
4562+
const markRateLimitedWithReason = vi.fn();
4563+
const manager = {
4564+
getAccountCount: () => 1,
4565+
getCurrentOrNextForFamilyHybrid: () => ({ index: 0, accountId: "acc-1", email: "alpha@example.com", refreshToken: "refresh-1" }),
4566+
getCurrentOrNextForFamily: () => ({ index: 0, accountId: "acc-1", email: "alpha@example.com", refreshToken: "refresh-1" }),
4567+
getCurrentWorkspace: () => null,
4568+
getAccountByIndex: () => null,
4569+
getAccountsSnapshot: () => [],
4570+
isAccountAvailableForFamily: () => true,
4571+
toAuthDetails: () => ({ type: "oauth" as const, access: "access-token", refresh: "refresh-1", expires: Date.now() + 60_000 }),
4572+
hasRefreshToken: () => true,
4573+
saveToDiskDebounced: () => {},
4574+
updateFromAuth: () => {},
4575+
clearAuthFailures: () => {},
4576+
incrementAuthFailures: () => 1,
4577+
saveToDisk: async () => {},
4578+
markAccountCoolingDown: () => {},
4579+
markRateLimited: () => {},
4580+
markRateLimitedWithReason,
4581+
consumeToken: () => true,
4582+
refundToken: () => {},
4583+
syncCodexCliActiveSelectionForIndex: async () => {},
4584+
markSwitched: () => {},
4585+
removeAccount: () => {},
4586+
recordFailure: () => {},
4587+
recordSuccess: () => {},
4588+
recordRateLimit: () => {},
4589+
getMinWaitTimeForFamily: () => 0,
4590+
shouldShowAccountToast: () => false,
4591+
markToastShown: () => {},
4592+
setActiveIndex: () => null,
4593+
};
4594+
vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(manager as never);
4595+
// Entitlement error: mapUsageLimit404WithBody remaps 404 -> 403 (not 429)
4596+
vi.mocked(fetchHelpersModule.handleErrorResponse).mockResolvedValueOnce({
4597+
response: new Response(
4598+
JSON.stringify({ error: { code: "usage_not_included", message: "Not included in your plan" } }),
4599+
{ status: 403 },
4600+
),
4601+
rateLimit: undefined,
4602+
errorBody: JSON.stringify({ error: { code: "usage_not_included", message: "Not included in your plan" } }),
4603+
} as never);
4604+
globalThis.fetch = vi
4605+
.fn()
4606+
.mockResolvedValueOnce(new Response(JSON.stringify({ error: { code: "usage_not_included" } }), { status: 404 }));
4607+
4608+
const mockClient = createMockClient();
4609+
const { OpenAIOAuthPlugin } = await import("../index.js");
4610+
const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType;
4611+
const sdk = await plugin.auth.loader(getOAuthAuth, { options: {}, models: {} });
4612+
const response = await sdk.fetch!("https://api.openai.com/v1/chat/completions", {
4613+
method: "POST",
4614+
body: JSON.stringify({ model: "gpt-5.1" }),
4615+
});
4616+
4617+
// Entitlement 403 passes through; no rate-limit rotation
4618+
expect(response.status).toBe(403);
4619+
expect(markRateLimitedWithReason).not.toHaveBeenCalled();
4620+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
4621+
});
4622+
4623+
it("rotates account when upstream 404 body contains rate_limit_exceeded keyword", async () => {
4624+
const { AccountManager } = await import("../lib/accounts.js");
4625+
const fetchHelpersModule = await import("../lib/request/fetch-helpers.js");
4626+
const rateLimitBackoffModule = await import("../lib/request/rate-limit-backoff.js");
4627+
4628+
const markRateLimitedWithReason = vi.fn();
4629+
const manager = {
4630+
getAccountCount: () => 1,
4631+
getCurrentOrNextForFamilyHybrid: () => ({ index: 0, accountId: "acc-1", email: "alpha@example.com", refreshToken: "refresh-1" }),
4632+
getCurrentOrNextForFamily: () => ({ index: 0, accountId: "acc-1", email: "alpha@example.com", refreshToken: "refresh-1" }),
4633+
getCurrentWorkspace: () => null,
4634+
getAccountByIndex: () => null,
4635+
getAccountsSnapshot: () => [],
4636+
isAccountAvailableForFamily: () => true,
4637+
toAuthDetails: () => ({ type: "oauth" as const, access: "access-token", refresh: "refresh-1", expires: Date.now() + 60_000 }),
4638+
hasRefreshToken: () => true,
4639+
saveToDiskDebounced: () => {},
4640+
updateFromAuth: () => {},
4641+
clearAuthFailures: () => {},
4642+
incrementAuthFailures: () => 1,
4643+
saveToDisk: async () => {},
4644+
markAccountCoolingDown: () => {},
4645+
markRateLimited: () => {},
4646+
markRateLimitedWithReason,
4647+
consumeToken: () => true,
4648+
refundToken: () => {},
4649+
syncCodexCliActiveSelectionForIndex: async () => {},
4650+
markSwitched: () => {},
4651+
removeAccount: () => {},
4652+
recordFailure: () => {},
4653+
recordSuccess: () => {},
4654+
recordRateLimit: () => {},
4655+
getMinWaitTimeForFamily: () => 0,
4656+
shouldShowAccountToast: () => true,
4657+
markToastShown: () => {},
4658+
setActiveIndex: () => null,
4659+
};
4660+
vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(manager as never);
4661+
vi.mocked(fetchHelpersModule.handleErrorResponse).mockResolvedValueOnce({
4662+
response: new Response(
4663+
JSON.stringify({ error: { code: "rate_limit_exceeded", message: "Rate limit exceeded" } }),
4664+
{ status: 429 },
4665+
),
4666+
rateLimit: { retryAfterMs: 3 * 60 * 60 * 1000, code: "rate_limit_exceeded" },
4667+
errorBody: JSON.stringify({ error: { code: "rate_limit_exceeded", message: "Rate limit exceeded" } }),
4668+
} as never);
4669+
vi.mocked(rateLimitBackoffModule.getRateLimitBackoff).mockReturnValueOnce({ attempt: 1, delayMs: 500 });
4670+
globalThis.fetch = vi
4671+
.fn()
4672+
.mockResolvedValueOnce(new Response(JSON.stringify({ error: { code: "rate_limit_exceeded" } }), { status: 404 }))
4673+
.mockResolvedValueOnce(new Response(JSON.stringify({ content: "ok" }), { status: 200 }));
4674+
4675+
const mockClient = createMockClient();
4676+
const { OpenAIOAuthPlugin } = await import("../index.js");
4677+
const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType;
4678+
const sdk = await plugin.auth.loader(getOAuthAuth, { options: {}, models: {} });
4679+
const response = await sdk.fetch!("https://api.openai.com/v1/chat/completions", {
4680+
method: "POST",
4681+
body: JSON.stringify({ model: "gpt-5.1" }),
4682+
});
4683+
4684+
expect(response.status).toBe(503);
4685+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
4686+
expect(markRateLimitedWithReason).toHaveBeenCalledWith(
4687+
expect.objectContaining({ index: 0 }),
4688+
3 * 60 * 60 * 1000,
4689+
"gpt-5.1",
4690+
expect.any(String),
4691+
"gpt-5.1",
4692+
);
4693+
});
4694+
44304695
it("persists the longer parsed rate-limit cooldown across overlapping requests", async () => {
44314696
const { AccountManager } = await import("../lib/accounts.js");
44324697
const { AccountManager: ActualAccountManager } =

0 commit comments

Comments
 (0)