Skip to content

Commit 7146102

Browse files
committed
feat: auto-rotate on disabled/expired workspace errors
1 parent 919a016 commit 7146102

3 files changed

Lines changed: 147 additions & 13 deletions

File tree

index.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ import {
147147
rewriteUrlForCodex,
148148
shouldRefreshToken,
149149
transformRequestForCodex,
150+
isWorkspaceDisabledError,
150151
} from "./lib/request/fetch-helpers.js";
151152
import { applyFastSessionDefaults } from "./lib/request/request-transformer.js";
152153
import {
@@ -1942,19 +1943,51 @@ while (attempted.size < Math.max(1, accountCount)) {
19421943
blockedModel,
19431944
);
19441945
}
1945-
if (errorResponse.status === 403 && !unsupportedModelInfo.isUnsupported) {
1946-
entitlementCache.markBlocked(
1947-
entitlementAccountKey,
1948-
model ?? modelFamily,
1949-
"plan-entitlement",
1946+
if (errorResponse.status === 403 && !unsupportedModelInfo.isUnsupported) {
1947+
entitlementCache.markBlocked(
1948+
entitlementAccountKey,
1949+
model ?? modelFamily,
1950+
"plan-entitlement",
1951+
);
1952+
capabilityPolicyStore.recordFailure(
1953+
entitlementAccountKey,
1954+
capabilityModelKey,
1955+
);
1956+
}
1957+
1958+
// Handle workspace disabled/expired errors by marking account as disabled and rotating
1959+
if (errorResponse.status === 403 && errorBody) {
1960+
const errorCode = (errorBody as { error?: { code?: string } })?.error?.code ?? "";
1961+
const errorMessage = (errorBody as { error?: { message?: string } })?.error?.message ?? "";
1962+
1963+
if (isWorkspaceDisabledError(errorResponse.status, errorCode, errorMessage)) {
1964+
runtimeMetrics.failedRequests++;
1965+
runtimeMetrics.accountRotations++;
1966+
runtimeMetrics.lastError = `Workspace disabled for account ${account.index + 1}`;
1967+
1968+
logWarn(
1969+
`Workspace disabled/expired for account ${account.index + 1} (${account.email ?? "unknown"}). Disabling account and rotating.`,
1970+
{ errorCode, errorMessage },
19501971
);
1951-
capabilityPolicyStore.recordFailure(
1952-
entitlementAccountKey,
1953-
capabilityModelKey,
1972+
1973+
// Mark the account as disabled
1974+
accountManager.setAccountEnabled(account.index, false);
1975+
accountManager.saveToDiskDebounced();
1976+
1977+
// Show a toast notification
1978+
await showToast(
1979+
`Workspace disabled for account ${account.index + 1}. Switching to another account.`,
1980+
"warning",
1981+
{ duration: toastDurationMs },
19541982
);
1983+
1984+
// Forget session affinity and rotate to next account
1985+
sessionAffinityStore?.forgetSession(sessionAffinityKey);
1986+
break;
19551987
}
1988+
}
19561989

1957-
if (recoveryHook && errorBody && isRecoverableError(errorBody)) {
1990+
if (recoveryHook && errorBody && isRecoverableError(errorBody)) {
19581991
const errorType = detectErrorType(errorBody);
19591992
const toastContent = getRecoveryToastContent(errorType);
19601993
await showToast(

lib/request/fetch-helpers.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,61 @@ export function isEntitlementError(code: string, bodyText: string): boolean {
254254
return /usage_not_included|not.included.in.your.plan|subscription.does.not.include/i.test(haystack);
255255
}
256256

257+
/**
258+
* Detects whether an error indicates the workspace/account has been disabled or expired.
259+
*
260+
* Workspace disabled errors signal that the current workspace is no longer accessible
261+
* (expired, disabled, or removed) and the plugin should automatically switch to another account.
262+
*
263+
* @param status - HTTP status code
264+
* @param code - The error code string returned by the service
265+
* @param bodyText - The response body text to inspect for workspace-related phrases
266+
* @returns `true` if the error indicates a disabled/expired workspace
267+
*/
268+
export function isWorkspaceDisabledError(status: number, code: string, bodyText: string): boolean {
269+
const haystack = `${code} ${bodyText}`.toLowerCase();
270+
271+
// Check for HTTP 403 with workspace disabled messages
272+
if (status === 403) {
273+
// Common patterns for disabled/expired workspaces
274+
const disabledPatterns = [
275+
/workspace.*(?:disabled|expired|deactivated|terminated)/i,
276+
/account.*(?:disabled|expired|deactivated|terminated)/i,
277+
/(?:workspace|account).*no longer.*(?:active|available|valid)/i,
278+
/(?:workspace|account).*has been.*(?:disabled|expired|closed)/i,
279+
/workspace.*(?:access|subscription).*expired/i,
280+
/billing.*(?:failed|expired|disabled)/i,
281+
/payment.*(?:failed|expired|required)/i,
282+
/organization.*(?:disabled|expired|inactive)/i,
283+
/team.*(?:disabled|expired|inactive)/i,
284+
];
285+
286+
for (const pattern of disabledPatterns) {
287+
if (pattern.test(haystack)) {
288+
return true;
289+
}
290+
}
291+
}
292+
293+
// Check for specific error codes that indicate workspace issues
294+
const workspaceErrorCodes = [
295+
"workspace_disabled",
296+
"workspace_expired",
297+
"workspace_terminated",
298+
"account_disabled",
299+
"account_expired",
300+
"organization_disabled",
301+
"billing_failed",
302+
"payment_required",
303+
];
304+
305+
if (workspaceErrorCodes.some((c) => code.toLowerCase().includes(c))) {
306+
return true;
307+
}
308+
309+
return false;
310+
}
311+
257312
/**
258313
* Constructs a standardized 403 entitlement error Response indicating the user lacks access to Codex models.
259314
*

test/fetch-helpers.test.ts

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
handleErrorResponse,
1010
handleSuccessResponse,
1111
isEntitlementError,
12+
isWorkspaceDisabledError,
1213
createEntitlementErrorResponse,
1314
getUnsupportedCodexModelInfo,
1415
resolveUnsupportedCodexFallbackModel,
@@ -307,12 +308,57 @@ describe('Fetch Helpers Module', () => {
307308
expect(isEntitlementError('rate_limit_exceeded', '')).toBe(false);
308309
});
309310

310-
it('returns false for generic errors', () => {
311-
expect(isEntitlementError('not_found', 'Resource not found')).toBe(false);
312-
});
311+
it('returns false for generic errors', () => {
312+
expect(isEntitlementError('not_found', 'Resource not found')).toBe(false);
313+
});
314+
});
315+
316+
describe('isWorkspaceDisabledError', () => {
317+
it('returns true for 403 with workspace disabled message', () => {
318+
expect(isWorkspaceDisabledError(403, '', 'Your workspace has been disabled')).toBe(true);
319+
});
320+
321+
it('returns true for 403 with workspace expired message', () => {
322+
expect(isWorkspaceDisabledError(403, '', 'Workspace expired')).toBe(true);
323+
});
324+
325+
it('returns true for 403 with account disabled message', () => {
326+
expect(isWorkspaceDisabledError(403, '', 'Account has been deactivated')).toBe(true);
327+
});
328+
329+
it('returns true for workspace_disabled error code', () => {
330+
expect(isWorkspaceDisabledError(403, 'workspace_disabled', '')).toBe(true);
331+
});
332+
333+
it('returns true for workspace_expired error code', () => {
334+
expect(isWorkspaceDisabledError(403, 'workspace_expired', 'Some message')).toBe(true);
313335
});
314336

315-
describe('createEntitlementErrorResponse', () => {
337+
it('returns true for account_disabled error code', () => {
338+
expect(isWorkspaceDisabledError(403, 'account_disabled', '')).toBe(true);
339+
});
340+
341+
it('returns true for billing_failed error code', () => {
342+
expect(isWorkspaceDisabledError(403, 'billing_failed', '')).toBe(true);
343+
});
344+
345+
it('returns false for non-403 status even with disabled message', () => {
346+
expect(isWorkspaceDisabledError(400, '', 'Your workspace has been disabled')).toBe(false);
347+
expect(isWorkspaceDisabledError(401, '', 'Your workspace has been disabled')).toBe(false);
348+
expect(isWorkspaceDisabledError(500, '', 'Your workspace has been disabled')).toBe(false);
349+
});
350+
351+
it('returns false for 403 with unrelated messages', () => {
352+
expect(isWorkspaceDisabledError(403, '', 'Permission denied')).toBe(false);
353+
expect(isWorkspaceDisabledError(403, '', 'Not authorized')).toBe(false);
354+
});
355+
356+
it('returns false for entitlement errors', () => {
357+
expect(isWorkspaceDisabledError(403, 'usage_not_included', 'Not in your plan')).toBe(false);
358+
});
359+
});
360+
361+
describe('createEntitlementErrorResponse', () => {
316362
it('returns 403 status with user-friendly message', async () => {
317363
const resp = createEntitlementErrorResponse('original body');
318364
expect(resp.status).toBe(403);

0 commit comments

Comments
 (0)