Skip to content

Commit 7d6f815

Browse files
author
den
committed
fix: gracefully handle deactivated_workspace by removing only the dead workspace entry
- Add isDeactivatedWorkspaceError() detector in fetch-helpers.ts that matches HTTP 402 + detail.code/error.code === 'deactivated_workspace' - On deactivated_workspace response: flag the specific workspace entry (keyed by organizationId > accountId > refreshToken), remove it from rotation and failover to the next healthy workspace/account - Preserve sibling workspaces sharing the same refreshToken — only the dead workspace-entry is removed, not the entire user - Extend codex-health deep probe to flag deactivated workspaces using the same identity key (not refreshToken) so multi-workspace users aren't penalized for a single bad workspace - Add test: handleErrorResponse normalizes 402 deactivated_workspace - Add test: plugin removes only the dead workspace and succeeds on the live sibling in the same request
1 parent dffb51a commit 7d6f815

5 files changed

Lines changed: 307 additions & 15 deletions

File tree

index.ts

Lines changed: 109 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ import {
128128
extractRequestUrl,
129129
handleErrorResponse,
130130
handleSuccessResponse,
131+
isDeactivatedWorkspaceError,
131132
getUnsupportedCodexModelInfo,
132133
resolveUnsupportedCodexFallbackModel,
133134
refreshAndUpdateToken,
@@ -183,6 +184,27 @@ import {
183184
getRecoveryToastContent,
184185
} from "./lib/recovery.js";
185186

187+
function getWorkspaceIdentityKey(account: {
188+
organizationId?: string;
189+
accountId?: string;
190+
refreshToken: string;
191+
}): string {
192+
if (account.organizationId) return `organizationId:${account.organizationId}`;
193+
if (account.accountId) return `accountId:${account.accountId}`;
194+
return `refreshToken:${account.refreshToken}`;
195+
}
196+
197+
function matchesWorkspaceIdentity(
198+
account: {
199+
organizationId?: string;
200+
accountId?: string;
201+
refreshToken: string;
202+
},
203+
identityKey: string,
204+
): boolean {
205+
return getWorkspaceIdentityKey(account) === identityKey;
206+
}
207+
186208
/**
187209
* OpenAI Codex OAuth authentication plugin for opencode
188210
*
@@ -2358,6 +2380,62 @@ while (attempted.size < Math.max(1, accountCount)) {
23582380
threadId: threadIdCandidate,
23592381
});
23602382

2383+
const workspaceDeactivated = isDeactivatedWorkspaceError(errorBody, response.status);
2384+
if (workspaceDeactivated) {
2385+
const identityKey = getWorkspaceIdentityKey(account);
2386+
const accountLabel = formatAccountLabel(account, account.index);
2387+
accountManager.refundToken(account, modelFamily, model);
2388+
accountManager.recordFailure(account, modelFamily, model);
2389+
account.lastSwitchReason = "rotation";
2390+
runtimeMetrics.failedRequests++;
2391+
runtimeMetrics.accountRotations++;
2392+
runtimeMetrics.lastError = `Deactivated workspace on ${accountLabel}`;
2393+
runtimeMetrics.lastErrorCategory = "workspace-deactivated";
2394+
2395+
try {
2396+
const flaggedStorage = await loadFlaggedAccounts();
2397+
const flaggedRecord: FlaggedAccountMetadataV1 = {
2398+
...account,
2399+
flaggedAt: Date.now(),
2400+
flaggedReason: "workspace-deactivated",
2401+
lastError: "deactivated_workspace",
2402+
};
2403+
const existingIndex = flaggedStorage.accounts.findIndex((flagged) =>
2404+
matchesWorkspaceIdentity(flagged, identityKey),
2405+
);
2406+
if (existingIndex >= 0) {
2407+
flaggedStorage.accounts[existingIndex] = flaggedRecord;
2408+
} else {
2409+
flaggedStorage.accounts.push(flaggedRecord);
2410+
}
2411+
await saveFlaggedAccounts(flaggedStorage);
2412+
} catch (flagError) {
2413+
logWarn(
2414+
`Failed to persist deactivated workspace flag for ${accountLabel}: ${flagError instanceof Error ? flagError.message : String(flagError)}`,
2415+
);
2416+
}
2417+
2418+
if (accountManager.removeAccount(account)) {
2419+
accountManager.saveToDiskDebounced();
2420+
attempted.clear();
2421+
accountCount = accountManager.getAccountCount();
2422+
await showToast(
2423+
`Workspace deactivated. Removed ${accountLabel} from rotation and switching accounts.`,
2424+
"warning",
2425+
{ duration: toastDurationMs },
2426+
);
2427+
break;
2428+
}
2429+
2430+
accountManager.markAccountCoolingDown(
2431+
account,
2432+
ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS,
2433+
"auth-failure",
2434+
);
2435+
accountManager.saveToDiskDebounced();
2436+
break;
2437+
}
2438+
23612439
const unsupportedModelInfo = getUnsupportedCodexModelInfo(errorBody);
23622440
const hasRemainingAccounts = attempted.size < Math.max(1, accountCount);
23632441

@@ -2964,6 +3042,9 @@ while (attempted.size < Math.max(1, accountCount)) {
29643042
(typeof (errorBody as { error?: { message?: unknown } })?.error?.message === "string"
29653043
? (errorBody as { error?: { message?: string } }).error?.message
29663044
: bodyText) || `HTTP ${response.status}`;
3045+
if (isDeactivatedWorkspaceError(errorBody, response.status)) {
3046+
throw new Error("deactivated_workspace");
3047+
}
29673048
throw new Error(message);
29683049
}
29693050

@@ -3115,7 +3196,7 @@ while (attempted.size < Math.max(1, accountCount)) {
31153196
} else {
31163197
flaggedStorage.accounts.push(flaggedRecord);
31173198
}
3118-
removeFromActive.add(account.refreshToken);
3199+
removeFromActive.add(`refreshToken:${account.refreshToken}`);
31193200
flaggedChanged = true;
31203201
}
31213202
continue;
@@ -3194,13 +3275,31 @@ while (attempted.size < Math.max(1, accountCount)) {
31943275
console.log(
31953276
`[${i + 1}/${total}] ${label}: ${formatCodexQuotaLine(snapshot)}`,
31963277
);
3197-
} catch (error) {
3198-
errors += 1;
3199-
const message = error instanceof Error ? error.message : String(error);
3200-
console.log(
3201-
`[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 160)})`,
3202-
);
3203-
}
3278+
} catch (error) {
3279+
errors += 1;
3280+
const message = error instanceof Error ? error.message : String(error);
3281+
if (message.includes("deactivated_workspace")) {
3282+
const existingIndex = flaggedStorage.accounts.findIndex((flagged) =>
3283+
matchesWorkspaceIdentity(flagged, getWorkspaceIdentityKey(account)),
3284+
);
3285+
const flaggedRecord: FlaggedAccountMetadataV1 = {
3286+
...account,
3287+
flaggedAt: Date.now(),
3288+
flaggedReason: "workspace-deactivated",
3289+
lastError: message,
3290+
};
3291+
if (existingIndex >= 0) {
3292+
flaggedStorage.accounts[existingIndex] = flaggedRecord;
3293+
} else {
3294+
flaggedStorage.accounts.push(flaggedRecord);
3295+
}
3296+
removeFromActive.add(getWorkspaceIdentityKey(account));
3297+
flaggedChanged = true;
3298+
}
3299+
console.log(
3300+
`[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 160)})`,
3301+
);
3302+
}
32043303
} catch (error) {
32053304
errors += 1;
32063305
const message = error instanceof Error ? error.message : String(error);
@@ -3210,7 +3309,7 @@ while (attempted.size < Math.max(1, accountCount)) {
32103309

32113310
if (removeFromActive.size > 0) {
32123311
workingStorage.accounts = workingStorage.accounts.filter(
3213-
(account) => !removeFromActive.has(account.refreshToken),
3312+
(account) => !removeFromActive.has(getWorkspaceIdentityKey(account)),
32143313
);
32153314
clampActiveIndices(workingStorage);
32163315
storageChanged = true;
@@ -3228,7 +3327,7 @@ while (attempted.size < Math.max(1, accountCount)) {
32283327
console.log(`Results: ${ok} ok, ${errors} error, ${disabled} disabled`);
32293328
if (removeFromActive.size > 0) {
32303329
console.log(
3231-
`Moved ${removeFromActive.size} account(s) to flagged pool (invalid refresh token).`,
3330+
`Moved ${removeFromActive.size} account(s) to flagged pool.`,
32323331
);
32333332
}
32343333
console.log("");

lib/request/fetch-helpers.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,35 @@ export interface ErrorDiagnostics {
279279
httpStatus?: number;
280280
}
281281

282+
const DEACTIVATED_WORKSPACE_CODE = "deactivated_workspace";
283+
284+
function getStructuredErrorCode(errorBody: unknown): string | undefined {
285+
if (!isRecord(errorBody)) return undefined;
286+
287+
const directCode = errorBody.code;
288+
if (typeof directCode === "string" && directCode.trim()) return directCode.trim();
289+
290+
const detail = errorBody.detail;
291+
if (isRecord(detail)) {
292+
const detailCode = detail.code;
293+
if (typeof detailCode === "string" && detailCode.trim()) return detailCode.trim();
294+
}
295+
296+
const nestedError = errorBody.error;
297+
if (isRecord(nestedError)) {
298+
const nestedCode = nestedError.code ?? nestedError.type;
299+
if (typeof nestedCode === "string" && nestedCode.trim()) return nestedCode.trim();
300+
}
301+
302+
return undefined;
303+
}
304+
305+
export function isDeactivatedWorkspaceError(errorBody: unknown, status?: number): boolean {
306+
if (status !== undefined && status !== 402) return false;
307+
const code = getStructuredErrorCode(errorBody);
308+
return code === DEACTIVATED_WORKSPACE_CODE;
309+
}
310+
282311
/**
283312
* Determines if the current auth token needs to be refreshed
284313
* @param auth - Current authentication state
@@ -730,6 +759,21 @@ function normalizeErrorPayload(
730759
status: number,
731760
diagnostics?: ErrorDiagnostics,
732761
): ErrorPayload {
762+
if (isDeactivatedWorkspaceError(errorBody, status)) {
763+
const payload: ErrorPayload = {
764+
error: {
765+
message:
766+
"The selected ChatGPT workspace is deactivated. This workspace entry should be removed from rotation or re-authorized before retrying.",
767+
type: "workspace_deactivated",
768+
code: DEACTIVATED_WORKSPACE_CODE,
769+
},
770+
};
771+
if (diagnostics && Object.keys(diagnostics).length > 0) {
772+
payload.error.diagnostics = diagnostics;
773+
}
774+
return payload;
775+
}
776+
733777
if (isUnsupportedCodexModelForChatGpt(status, bodyText)) {
734778
const unsupportedModel =
735779
extractUnsupportedCodexModelFromText(bodyText) ?? "requested model";

test/fetch-helpers.test.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
handleErrorResponse,
1010
handleSuccessResponse,
1111
isEntitlementError,
12+
isDeactivatedWorkspaceError,
1213
createEntitlementErrorResponse,
1314
getUnsupportedCodexModelInfo,
1415
resolveUnsupportedCodexFallbackModel,
@@ -21,9 +22,9 @@ import type { Auth } from '../lib/types.js';
2122
import { URL_PATHS, OPENAI_HEADERS, OPENAI_HEADER_VALUES, CODEX_BASE_URL } from '../lib/constants.js';
2223

2324
describe('Fetch Helpers Module', () => {
24-
afterEach(() => {
25-
vi.restoreAllMocks();
26-
});
25+
afterEach(() => {
26+
vi.restoreAllMocks();
27+
});
2728

2829
describe('shouldRefreshToken', () => {
2930
it('should return true for non-oauth auth', () => {
@@ -487,8 +488,21 @@ describe('Fetch Helpers Module', () => {
487488
});
488489
});
489490

490-
describe('handleErrorResponse error normalization', () => {
491-
it('extracts nested error.message', async () => {
491+
describe('handleErrorResponse error normalization', () => {
492+
it('normalizes deactivated workspace errors with dedicated code', async () => {
493+
const body = { detail: { code: 'deactivated_workspace' } };
494+
const response = new Response(JSON.stringify(body), { status: 402, statusText: 'Payment Required' });
495+
496+
const { response: result, errorBody } = await handleErrorResponse(response);
497+
const json = await result.json() as { error: { message: string; type?: string; code?: string } };
498+
499+
expect(isDeactivatedWorkspaceError(errorBody, 402)).toBe(true);
500+
expect(json.error.code).toBe('deactivated_workspace');
501+
expect(json.error.type).toBe('workspace_deactivated');
502+
expect(json.error.message).toContain('workspace is deactivated');
503+
});
504+
505+
it('extracts nested error.message', async () => {
492506
const body = { error: { message: 'nested error message', type: 'test_type', code: 'test_code' } };
493507
const response = new Response(JSON.stringify(body), { status: 500 });
494508

test/index-retry.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ vi.mock("../lib/request/fetch-helpers.js", () => ({
2424
refreshAndUpdateToken: async (auth: any) => auth,
2525
createCodexHeaders: () => new Headers(),
2626
handleErrorResponse: async (response: Response) => ({ response }),
27+
isDeactivatedWorkspaceError: () => false,
2728
resolveUnsupportedCodexFallbackModel: () => undefined,
2829
shouldFallbackToGpt52OnUnsupportedGpt53: () => false,
2930
handleSuccessResponse: async (response: Response) => response,

0 commit comments

Comments
 (0)