Skip to content

Commit c4c4200

Browse files
committed
fix(codex): map refresh lock timeouts and empty WHAM plans
Treat credential-writer lock remaps as retryable 503s at the management boundary, normalize OAuth WHAM plan_type via nonEmptyPlan, and cover the busy path with a Host-aware management API regression.
1 parent c310f3c commit c4c4200

3 files changed

Lines changed: 51 additions & 2 deletions

File tree

src/codex/auth-api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1211,7 +1211,7 @@ export async function handleCodexAuthAPI(
12111211
if (resp.ok) {
12121212
const data = (await resp.json()) as WhamUsageResponse;
12131213
email = data.email ?? email;
1214-
plan = data.plan_type ?? undefined;
1214+
plan = nonEmptyPlan(data.plan_type) ?? undefined;
12151215
quota = parseUsageQuota(data);
12161216
}
12171217
} catch { /* wham fetch is non-blocking */ }

src/server/management-api.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,13 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
168168
if (url.pathname.startsWith("/api/codex-auth/")) {
169169
const { handleCodexAuthAPI } = await import("../codex/auth-api");
170170
const { ConfigMutationLockError } = await import("../config");
171+
const { CodexCredentialRefreshLockTimeoutError } = await import("../codex/account-store");
171172
try {
172173
return await handleCodexAuthAPI(req, url, config);
173174
} catch (error) {
174-
if (error instanceof ConfigMutationLockError) {
175+
// Credential writers remap ConfigMutationLockError to CodexCredentialRefreshLockTimeoutError;
176+
// treat both as the same retryable busy response.
177+
if (error instanceof ConfigMutationLockError || error instanceof CodexCredentialRefreshLockTimeoutError) {
175178
return jsonResponse(
176179
{ error: "Configuration is busy; retry shortly", code: "CONFIG_MUTATION_LOCK_UNAVAILABLE" },
177180
503,

tests/config-mutation-lock.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url";
55
import { ConfigMutationLockError, loadConfig, saveConfig, withConfigMutationLockSync } from "../src/config";
66
import { CodexCredentialRefreshLockTimeoutError, getCodexAccountCredential, saveCodexAccountCredential } from "../src/codex/account-store";
77
import type { OcxConfig } from "../src/types";
8+
import { ManagementRequest, managementHeaders } from "./helpers/management-auth";
89

910
let testRoot = "";
1011
let previousOpencodexHome: string | undefined;
@@ -135,3 +136,48 @@ test("a throwing mutation releases the lock and leaves writers available", () =>
135136
expect(() => saveConfig(config(50500))).not.toThrow();
136137
expect(loadConfig().port).toBe(50500);
137138
});
139+
140+
test("management API maps config mutation lock contention to retryable 503", async () => {
141+
saveConfig(config());
142+
const readyPath = join(testRoot, "mgmt-holder-ready");
143+
const releasePath = join(testRoot, "mgmt-holder-release");
144+
const configModuleUrl = pathToFileURL(join(import.meta.dir, "../src/config.ts")).href;
145+
const childSource = `
146+
import { existsSync, writeFileSync } from "node:fs";
147+
import { withConfigMutationLockSync } from ${JSON.stringify(configModuleUrl)};
148+
withConfigMutationLockSync(() => {
149+
writeFileSync(${JSON.stringify(readyPath)}, "ready");
150+
while (!existsSync(${JSON.stringify(releasePath)})) Bun.sleepSync(10);
151+
});
152+
`;
153+
const child = Bun.spawn([process.execPath, "-e", childSource], {
154+
cwd: join(import.meta.dir, ".."),
155+
env: { ...process.env, OPENCODEX_HOME: testRoot },
156+
stdin: "ignore",
157+
stdout: "pipe",
158+
stderr: "pipe",
159+
});
160+
161+
try {
162+
await waitForPath(readyPath);
163+
const { handleManagementAPI } = await import("../src/server/management-api");
164+
const url = new URL("http://localhost/api/codex-auth/auto-switch");
165+
const response = await handleManagementAPI(
166+
new ManagementRequest(url, {
167+
method: "PUT",
168+
headers: managementHeaders({ "content-type": "application/json" }),
169+
body: JSON.stringify({ threshold: 50 }),
170+
}),
171+
url,
172+
config(),
173+
);
174+
expect(response?.status).toBe(503);
175+
expect(await response!.json()).toMatchObject({
176+
error: "Configuration is busy; retry shortly",
177+
code: "CONFIG_MUTATION_LOCK_UNAVAILABLE",
178+
});
179+
} finally {
180+
writeFileSync(releasePath, "release");
181+
expect(await waitForOwnedChild(child)).toBe(0);
182+
}
183+
});

0 commit comments

Comments
 (0)