Skip to content
Merged
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
6 changes: 6 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2282,6 +2282,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
modelFamily,
model,
);
// Persist the rate-limit window like the full-rotation
// branch below (line ~2327). markRateLimitedWithReason
// mutates `rateLimitResetTimes`, which is serialized to
// disk; without this a crash during the retry sleep loses
// the cooldown and the account is re-selected on restart.
accountManager.saveToDiskDebounced();

if (
accountManager.shouldShowAccountToast(
Expand Down
100 changes: 100 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5546,6 +5546,106 @@ describe("OpenAIOAuthPlugin runtime toast forwarding", () => {
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});

it("persists rate-limit window to disk on short-cooldown 429 (saveToDiskDebounced regression)", async () => {
const { AccountManager } = await import("../lib/accounts.js");
const fetchHelpersModule = await import("../lib/request/fetch-helpers.js");
const rateLimitBackoffModule = await import(
"../lib/request/rate-limit-backoff.js"
);

const saveToDiskDebounced = vi.fn();
const manager = {
getAccountCount: () => 1,
getCurrentOrNextForFamilyHybrid: () => ({
index: 0,
accountId: "acc-1",
email: "alpha@example.com",
refreshToken: "refresh-1",
}),
getCurrentOrNextForFamily: () => ({
index: 0,
accountId: "acc-1",
email: "alpha@example.com",
refreshToken: "refresh-1",
}),
getCurrentWorkspace: () => null,
getAccountByIndex: () => null,
getAccountsSnapshot: () => [],
isAccountAvailableForFamily: () => true,
toAuthDetails: () => ({
type: "oauth" as const,
access: "access-token",
refresh: "refresh-1",
expires: Date.now() + 60_000,
}),
hasRefreshToken: () => true,
saveToDiskDebounced,
updateFromAuth: () => {},
clearAuthFailures: () => {},
incrementAuthFailures: () => 1,
saveToDisk: async () => {},
markAccountCoolingDown: () => {},
markRateLimited: () => {},
markRateLimitedWithReason: () => {},
consumeToken: () => true,
refundToken: () => {},
syncCodexCliActiveSelectionForIndex: async () => {},
markSwitched: () => {},
removeAccount: () => {},
recordFailure: () => {},
recordSuccess: () => {},
recordRateLimit: () => {},
getMinWaitTimeForFamily: () => 0,
shouldShowAccountToast: () => false,
markToastShown: () => {},
setActiveIndex: () => null,
};
vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(
manager as never,
);
// Short cooldown: 1000ms < 5000ms threshold -> short-retry branch
vi.mocked(fetchHelpersModule.handleErrorResponse).mockResolvedValueOnce({
response: new Response("rate limited", { status: 429 }),
rateLimit: { retryAfterMs: 1000, code: "rate_limit_exceeded" },
errorBody: "rate limited",
} as never);
vi.mocked(rateLimitBackoffModule.getRateLimitBackoff).mockReturnValueOnce({
attempt: 1,
delayMs: 500,
});
globalThis.fetch = vi
.fn()
.mockResolvedValueOnce(new Response("rate limited", { status: 429 }))
.mockResolvedValueOnce(
new Response(JSON.stringify({ content: "ok" }), { status: 200 }),
);

const mockClient = createMockClient();
const { OpenAIOAuthPlugin } = await import("../index.js");
const plugin = (await OpenAIOAuthPlugin({
client: mockClient,
} as never)) as unknown as PluginType;
const sdk = await plugin.auth.loader(getOAuthAuth, {
options: {},
models: {},
});
const response = await sdk.fetch!(
"https://api.openai.com/v1/chat/completions",
{
method: "POST",
body: JSON.stringify({ model: "gpt-5.1" }),
},
);

// The request should ultimately succeed (short-retry -> 200)
expect(response.status).toBe(200);
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
// Regression guard: the short-retry branch MUST call saveToDiskDebounced so
// the rateLimitResetTimes mutation from markRateLimitedWithReason survives a
// crash during the retry sleep (mirrors the full-rotation branch below it).
expect(saveToDiskDebounced).toHaveBeenCalledTimes(1);
});

it("does not rotate on 404 with unrelated body (not a usage limit)", async () => {
const { AccountManager } = await import("../lib/accounts.js");
const fetchHelpersModule = await import("../lib/request/fetch-helpers.js");
Expand Down